Adding first working version

This commit is contained in:
2026-07-06 13:11:48 +02:00
parent 3cd9df9bfe
commit 41e9c76028
36 changed files with 6211 additions and 4391 deletions
+10 -5
View File
@@ -13,23 +13,28 @@ The original embeddable GlitchComponent has been moved to the sibling folder:
Start the database: Start the database:
```bash ```bash
cd backend docker compose up -d postgres
docker compose up -d ```
Create `.env` from the local example:
```bash
cp .env.example .env
``` ```
Run migrations: Run migrations:
```bash ```bash
DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test npm run migrate npm run migrate
``` ```
Start the API: Start the API:
```bash ```bash
DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test npm run dev npm run dev
``` ```
In another terminal, start the admin UI: In another terminal, start only the admin UI if needed:
```bash ```bash
npm run admin:dev npm run admin:dev
+4 -1
View File
@@ -531,7 +531,10 @@ registerCaRendererRuntime(
id: 'voxel-3d', id: 'voxel-3d',
label: 'Voxel 3D', label: 'Voxel 3D',
aliases: ['three-voxel'], aliases: ['three-voxel'],
supportedClasses: [{ dimensions: 3, states: 2 }], supportedClasses: [
{ dimensions: 3, states: 2 },
{ dimensions: 3, states: 7 }
],
Component: Voxel3DRendererAdapter Component: Voxel3DRendererAdapter
}, },
{ replace: true } { replace: true }
+12 -2
View File
@@ -570,9 +570,19 @@ function NodePropertiesModal({
<p className="eyebrow">Preset Editor</p> <p className="eyebrow">Preset Editor</p>
<h2>Edit preset</h2> <h2>Edit preset</h2>
</div> </div>
<button type="button" onClick={onClose}> <span className="win95-window-controls has-close">
Close <span className="window-control-decor">_</span>
<span className="window-control-decor"></span>
<button
aria-label="Close"
className="window-control-button window-close-button"
title="Close"
type="button"
onClick={onClose}
>
×
</button> </button>
</span>
</header> </header>
<div className="property-modal-list"> <div className="property-modal-list">
<section className="preset-modal-stage preset-modal-identity"> <section className="preset-modal-stage preset-modal-identity">
@@ -0,0 +1,5 @@
import type { Cells } from '../types.js'
export function emptyCellsLike(cells: Cells): Cells {
return cells.map((row) => row.map(() => false))
}
@@ -0,0 +1,30 @@
import { cloneCells, resolveBoundaryCondition, resolveBoundaryIndex } from '../caRuntime.js'
import type { Cells, SceneParams } from '../types.js'
function parseElementaryRule(ruleId: string | undefined) {
const match = /(?:rule[-_\s]*)?(\d{1,3})/i.exec(ruleId ?? '')
if (!match) return 110
return Math.max(0, Math.min(255, Number(match[1])))
}
export function stepElementary1d(cells: Cells, settings: SceneParams) {
const rule = parseElementaryRule(settings.simulation?.ruleId)
const next = cloneCells(cells)
const width = cells[0]?.length ?? 0
if (cells.length === 0 || width === 0) return next
const boundary = resolveBoundaryCondition(settings.simulation?.grid)
const sourceRowIndex = Math.max(0, cells.findIndex((row) => row.some(Boolean)))
const sourceRow = cells[sourceRowIndex] ?? []
for (let x = 0; x < width; x += 1) {
const leftIndex = resolveBoundaryIndex(x - 1, width, boundary)
const rightIndex = resolveBoundaryIndex(x + 1, width, boundary)
const left = leftIndex === null ? 0 : sourceRow[leftIndex] ? 1 : 0
const center = sourceRow[x] ? 1 : 0
const right = rightIndex === null ? 0 : sourceRow[rightIndex] ? 1 : 0
const pattern = (left << 2) | (center << 1) | right
next[sourceRowIndex][x] = ((rule >> pattern) & 1) === 1
}
return next
}
+420
View File
@@ -0,0 +1,420 @@
import { resolveBoundaryCondition, resolveBoundaryIndex, seededRandom } from '../caRuntime.js'
import type { BoundaryCondition, Cells, JsonObject, SceneParams } from '../types.js'
export type NagaArrow = 1 | 2 | 3 | 4 | 5 | 6
export type NagaSymbol = NagaArrow | null
export type NagaPosition = readonly [number, number, number]
export type NagaUniverse = Map<string, Moykle>
export interface FollowResult {
changes: number
emits: number
comment: string
}
export interface EvolveResult extends FollowResult {}
interface NagaTopology {
boundary: BoundaryCondition
size: readonly [number, number, number]
}
export class Moykle {
position: NagaPosition
forward: NagaSymbol
constructor(position: NagaPosition, arrow: NagaSymbol) {
this.position = position
this.forward = arrow
}
}
export class Universe {
readonly cells = new Map<string, Moykle>()
get(position: NagaPosition) {
return this.cells.get(addr(position))?.forward ?? null
}
set(moykle: Moykle) {
this.cells.set(addr(moykle.position), moykle)
}
}
function isArrow(value: unknown): value is NagaArrow {
return value === 1 || value === 2 || value === 3 || value === 4 || value === 5 || value === 6
}
function addPosition(left: NagaPosition, right: NagaPosition): NagaPosition {
return [left[0] + right[0], left[1] + right[1], left[2] + right[2]]
}
function subtractPosition(left: NagaPosition, right: NagaPosition): NagaPosition {
return [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
}
function resolvePosition(position: NagaPosition, topology: NagaTopology): NagaPosition | null {
const x = resolveBoundaryIndex(position[0], topology.size[0], topology.boundary)
const y = resolveBoundaryIndex(position[1], topology.size[1], topology.boundary)
const z = resolveBoundaryIndex(position[2], topology.size[2], topology.boundary)
if (x === null || y === null || z === null) return null
return [x, y, z]
}
function translatePosition(position: NagaPosition, offset: NagaPosition, topology: NagaTopology) {
return resolvePosition(addPosition(position, offset), topology)
}
function reverseTranslatePosition(position: NagaPosition, offset: NagaPosition, topology: NagaTopology) {
return resolvePosition(subtractPosition(position, offset), topology)
}
export function opposite(symbol: NagaArrow): NagaArrow {
return (((symbol + 2) % 6) + 1) as NagaArrow
}
export function fVec(symbol: NagaSymbol | 0): NagaPosition | null {
if (symbol === 1) return [1, 0, 0]
if (symbol === 2) return [0, 1, 0]
if (symbol === 3) return [0, 0, 1]
if (symbol === 4) return [-1, 0, 0]
if (symbol === 5) return [0, -1, 0]
if (symbol === 6) return [0, 0, -1]
return null
}
export function addr(position: NagaPosition) {
return `${position[0]}_${position[1]}_${position[2]}`
}
export function isHead(universe: NagaUniverse, moykle: Moykle, _time: number, topology: NagaTopology) {
let adjacentsThatPointToMoykle = 0
if (moykle.forward === null) return false
for (let i = 1 as NagaArrow; i <= 6; i = (i + 1) as NagaArrow) {
const offset = fVec(i)
if (!offset) continue
const adjacentPosition = translatePosition(moykle.position, offset, topology)
if (!adjacentPosition) continue
const adjacent = universe.get(addr(adjacentPosition))
if (!adjacent || adjacent.forward === null) continue
if (adjacent.forward !== opposite(i) && i !== moykle.forward) {
adjacentsThatPointToMoykle += 1
}
}
return adjacentsThatPointToMoykle === 0
}
export function getAllHeads(universe: NagaUniverse, _time: number, topology: NagaTopology) {
const heads: Moykle[] = []
for (const moykle of universe.values()) {
let apprentice = 0
let master = 0
if (moykle.forward === null) continue
for (let i = 1 as NagaArrow; i <= 6; i = (i + 1) as NagaArrow) {
const offset = fVec(i)
if (!offset) continue
const adjacentPosition = translatePosition(moykle.position, offset, topology)
if (!adjacentPosition) continue
const adjacent = universe.get(addr(adjacentPosition))
if (!adjacent || adjacent.forward === null) continue
if (adjacent.forward === opposite(i)) master += 1
if (i === moykle.forward) apprentice += 1
}
if (apprentice <= 1 && master === 0) heads.push(moykle)
}
return heads
}
export function moykleNumber(universe: NagaUniverse, moykle: Moykle, _time: number, topology: NagaTopology) {
let pointsToOccupied = 0
let pointedAtBy = 0
for (let i = 1 as NagaArrow; i <= 6; i = (i + 1) as NagaArrow) {
const offset = fVec(i)
if (!offset) continue
const adjacentPosition = translatePosition(moykle.position, offset, topology)
if (!adjacentPosition) continue
const adjacent = universe.get(addr(adjacentPosition))
if (!adjacent || adjacent.forward === null) continue
if (adjacent.forward === opposite(i)) pointedAtBy += 1
if (i === moykle.forward) pointsToOccupied += 1
}
return [pointsToOccupied, pointedAtBy] as const
}
export function createNagaRandom(seed: string | number = 100) {
return seededRandom(String(seed))
}
export function evolve(universe: NagaUniverse, time: number, topology: NagaTopology, random = createNagaRandom()): EvolveResult {
const headList = getAllHeads(universe, time, topology)
if (headList.length === 0) throw new Error('Cannot evolve, no heads')
const selected = headList[Math.floor(random() * headList.length)] ?? headList[0]
return follow(universe, selected, time, topology, 0)
}
export function follow(universe: NagaUniverse, head: Moykle, time: number, topology: NagaTopology, _depth: number): FollowResult {
let changes = 0
let emits = 0
let current = head
let tail = head
let tailSearching = true
const tailVisited = new Set<string>()
while (tailSearching) {
const currentAddress = addr(current.position)
if (tailVisited.has(currentAddress)) {
tailSearching = false
tail = current
break
}
tailVisited.add(currentAddress)
if (current.forward === null) break
const offset = fVec(current.forward)
if (!offset) break
const nextPosition = translatePosition(current.position, offset, topology)
if (!nextPosition) break
const next = universe.get(addr(nextPosition))
if (next && isArrow(next.forward)) {
current = next
tailSearching = true
} else {
tailSearching = false
tail = current
}
}
if (tail.forward === null) {
return {
changes,
emits,
comment: `T:${time} | Tail has no symbol to move.`
}
}
const tailOffset = fVec(tail.forward)
if (!tailOffset) {
return {
changes,
emits,
comment: `T:${time} | Tail symbol has no direction.`
}
}
const placePosition = reverseTranslatePosition(head.position, tailOffset, topology)
if (!placePosition) {
return {
changes,
emits,
comment: `T:${time} | Tail would move outside fixed boundary.`
}
}
const placeAddress = addr(placePosition)
if (!universe.has(placeAddress)) {
universe.set(placeAddress, new Moykle(placePosition, null))
}
const place = universe.get(placeAddress)
if (!place) throw new Error(`Unable to create naga place cell at ${placeAddress}`)
if (head.forward === opposite(tail.forward)) {
let comment = `T:${time} | Emission! ${place.position.join(',')} implied contradiction.`
let kinkSearching = true
current = universe.get(addr(head.position)) ?? head
const kinkVisited = new Set<string>()
while (kinkSearching) {
const currentAddress = addr(current.position)
if (kinkVisited.has(currentAddress)) break
kinkVisited.add(currentAddress)
if (current.forward === null) break
const currentOffset = fVec(current.forward)
if (!currentOffset) break
const nextPosition = translatePosition(current.position, currentOffset, topology)
if (!nextPosition) break
const next = universe.get(addr(nextPosition))
if (next && isArrow(next.forward)) {
current = next
const kinkPlacePosition = reverseTranslatePosition(current.position, tailOffset, topology)
if (!kinkPlacePosition) {
kinkSearching = false
break
}
const kinkPlace = universe.get(addr(kinkPlacePosition))
kinkSearching = Boolean(kinkPlace && isArrow(kinkPlace.forward))
} else {
kinkSearching = false
}
}
const kinkPlacePosition = reverseTranslatePosition(current.position, tailOffset, topology)
if (!kinkPlacePosition) {
return {
changes,
emits,
comment: `T:${time} | Kink would move outside fixed boundary.`
}
}
const kinkPlaceAddress = addr(kinkPlacePosition)
if (!universe.has(kinkPlaceAddress)) {
universe.set(kinkPlaceAddress, new Moykle(kinkPlacePosition, null))
}
const kinkPlace = universe.get(kinkPlaceAddress)
if (!kinkPlace) throw new Error(`Unable to create naga kink place cell at ${kinkPlaceAddress}`)
const temp = kinkPlace.forward
kinkPlace.forward = tail.forward
tail.forward = temp
emits += 1
changes += 1
comment += ` Kink at ${kinkPlacePosition.join(',')}, swapped ${kinkPlace.forward} for ${temp}.`
return { changes, emits, comment }
}
const comment = `T:${time} | Take tail symbol ${tail.forward} from ${tail.position.join(',')}, put into ${place.position.join(',')} in exchange for ${place.forward}`
const temp = place.forward
place.forward = tail.forward
tail.forward = temp
changes += 1
return { changes, emits, comment }
}
function gridSize(settings: SceneParams, cells: Cells) {
const size = settings.simulation?.grid?.size
const width = Array.isArray(size) && typeof size[0] === 'number' ? size[0] : cells[0]?.length ?? 24
const height = Array.isArray(size) && typeof size[1] === 'number' ? size[1] : cells.length || 24
const depth = Array.isArray(size) && typeof size[2] === 'number' ? size[2] : 24
return [Math.max(1, width), Math.max(1, height), Math.max(1, depth)] as const
}
function coordinateSymbol(coordinate: number[]) {
const state = coordinate[3] ?? 1
return isArrow(state) ? state : 1
}
function universeFromCoordinates(coordinates: number[][] | undefined) {
const universe: NagaUniverse = new Map()
for (const coordinate of coordinates ?? []) {
const [x, y, z = 0] = coordinate
if (!Number.isInteger(x) || !Number.isInteger(y) || !Number.isInteger(z)) continue
const position: NagaPosition = [x, y, z]
universe.set(addr(position), new Moykle(position, coordinateSymbol(coordinate)))
}
return universe
}
function seedUniverseFromCells(cells: Cells) {
const universe: NagaUniverse = new Map()
for (let y = 0; y < cells.length; y += 1) {
for (let x = 0; x < (cells[y]?.length ?? 0); x += 1) {
if (!cells[y][x]) continue
const position: NagaPosition = [x, y, 0]
universe.set(addr(position), new Moykle(position, 1))
}
}
return universe
}
function coordinatesFromUniverse(universe: NagaUniverse, size: readonly [number, number, number]) {
const coordinates: number[][] = []
for (const moykle of universe.values()) {
const [x, y, z] = moykle.position
if (moykle.forward === null) continue
if (x < 0 || y < 0 || z < 0 || x >= size[0] || y >= size[1] || z >= size[2]) continue
coordinates.push([x, y, z, moykle.forward])
}
return coordinates
}
function projectCoordinatesToCells(coordinates: number[][], cells: Cells) {
const next = cells.map((row) => row.map(() => false))
for (const coordinate of coordinates) {
const [x, y] = coordinate
if (!Number.isInteger(x) || !Number.isInteger(y)) continue
if (y < 0 || x < 0 || y >= next.length || x >= (next[y]?.length ?? 0)) continue
next[y][x] = true
}
return next
}
function ensureSimulation(settings: SceneParams) {
settings.simulation ??= {}
settings.simulation.initialCondition ??= {}
return settings.simulation as SceneParams['simulation'] & JsonObject
}
export function stepNaga3d(cells: Cells, settings: SceneParams) {
const simulation = ensureSimulation(settings)
const size = gridSize(settings, cells)
const coordinates = simulation.initialCondition?.cells
const universe = Array.isArray(coordinates) && coordinates.length > 0
? universeFromCoordinates(coordinates)
: seedUniverseFromCells(cells)
const tick = typeof simulation.nagaTick === 'number' ? simulation.nagaTick : 0
const seed = typeof simulation.seed === 'string' ? simulation.seed : 'naga'
const topology: NagaTopology = {
boundary: resolveBoundaryCondition(simulation.grid),
size
}
try {
evolve(universe, tick, topology, createNagaRandom(`${seed}:${tick}`))
} catch (error) {
if (!(error instanceof Error) || error.message !== 'Cannot evolve, no heads') throw error
}
const nextCoordinates = coordinatesFromUniverse(universe, size)
simulation.initialCondition = {
...simulation.initialCondition,
type: 'cells',
cells: nextCoordinates
}
simulation.nagaTick = tick + 1
return projectCoordinatesToCells(nextCoordinates, cells)
}
export const Addr = addr
export const Evolve = evolve
export const Follow = follow
export const FVec = fVec
export const Moykle_number = moykleNumber
export const Opposite = opposite
+6
View File
@@ -0,0 +1,6 @@
import { cloneCells } from '../caRuntime.js'
import type { Cells } from '../types.js'
export function stepNoop(cells: Cells) {
return cloneCells(cells)
}
@@ -0,0 +1,45 @@
import { resolveBoundaryCondition, resolveBoundaryIndex } from '../caRuntime.js'
import type { Cells, SceneParams } from '../types.js'
import { emptyCellsLike } from './cellGrid.js'
function parseOuterTotalisticRule(ruleId: string) {
const match = /^B([0-8]*)\/S([0-8]*)$/i.exec(ruleId)
if (!match) return { birth: new Set([3]), survival: new Set([2, 3]) }
return {
birth: new Set(match[1].split('').map(Number)),
survival: new Set(match[2].split('').map(Number))
}
}
export function stepOuterTotalistic2d(cells: Cells, settings: SceneParams) {
const rule = parseOuterTotalisticRule(settings.simulation?.ruleId ?? 'B3/S23')
const next = emptyCellsLike(cells)
const height = cells.length
const width = cells[0]?.length ?? 0
const boundary = resolveBoundaryCondition(settings.simulation?.grid)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let neighbours = 0
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx === 0 && dy === 0) continue
const rawX = x + dx
const rawY = y + dy
const nx = resolveBoundaryIndex(rawX, width, boundary)
const ny = resolveBoundaryIndex(rawY, height, boundary)
if (nx === null || ny === null) continue
if (cells[ny][nx]) neighbours += 1
}
}
next[y][x] = cells[y][x] ? rule.survival.has(neighbours) : rule.birth.has(neighbours)
}
}
return next
}
@@ -0,0 +1,49 @@
import { resolveBoundaryCondition, resolveBoundaryIndex, seededRandom } from '../caRuntime.js'
import type { Cells, SceneParams } from '../types.js'
import { emptyCellsLike } from './cellGrid.js'
function parseProbability(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback
}
function deterministicCellNoise(seed: string, x: number, y: number) {
const random = seededRandom(`${seed}:${x}:${y}`)
return random()
}
export function stepWildfire2d(cells: Cells, settings: SceneParams) {
const next = emptyCellsLike(cells)
const height = cells.length
const width = cells[0]?.length ?? 0
const boundary = resolveBoundaryCondition(settings.simulation?.grid)
const spreadProbability = parseProbability(settings.simulation?.spreadProbability, 1)
const seed = typeof settings.simulation?.seed === 'string' ? settings.simulation.seed : 'wildfire'
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (cells[y][x]) {
next[y][x] = false
continue
}
let burningNeighbours = 0
const offsets = [
[0, -1],
[1, 0],
[0, 1],
[-1, 0]
] as const
for (const [dx, dy] of offsets) {
const nx = resolveBoundaryIndex(x + dx, width, boundary)
const ny = resolveBoundaryIndex(y + dy, height, boundary)
if (nx === null || ny === null) continue
if (cells[ny][nx]) burningNeighbours += 1
}
next[y][x] = burningNeighbours > 0 && deterministicCellNoise(seed, x, y) < spreadProbability
}
}
return next
}
+29 -133
View File
@@ -1,10 +1,9 @@
import { import { assertSameCellsShape } from './caRuntime.js'
assertSameCellsShape, import { stepElementary1d } from './caEngineSteps/elementary1d.js'
cloneCells, import { stepNaga3d } from './caEngineSteps/naga.js'
resolveBoundaryCondition, import { stepNoop } from './caEngineSteps/noop.js'
resolveBoundaryIndex, import { stepOuterTotalistic2d } from './caEngineSteps/outerTotalistic2d.js'
seededRandom import { stepWildfire2d } from './caEngineSteps/wildfire2d.js'
} from './caRuntime.js'
import type { CaClass, Cells, JsonObject, SceneParams } from './types.js' import type { CaClass, Cells, JsonObject, SceneParams } from './types.js'
export interface CaEngineRuntime { export interface CaEngineRuntime {
@@ -15,132 +14,13 @@ export interface CaEngineRuntime {
step: (cells: Cells, settings: SceneParams) => Cells step: (cells: Cells, settings: SceneParams) => Cells
} }
export interface CaStepResult {
cells: Cells
settings: SceneParams
}
const engineRuntimes = new Map<string, CaEngineRuntime>() const engineRuntimes = new Map<string, CaEngineRuntime>()
function emptyCellsLike(cells: Cells): Cells {
return cells.map((row) => row.map(() => false))
}
function parseOuterTotalisticRule(ruleId: string) {
const match = /^B([0-8]*)\/S([0-8]*)$/i.exec(ruleId)
if (!match) return { birth: new Set([3]), survival: new Set([2, 3]) }
return {
birth: new Set(match[1].split('').map(Number)),
survival: new Set(match[2].split('').map(Number))
}
}
function parseElementaryRule(ruleId: string | undefined) {
const match = /(?:rule[-_\s]*)?(\d{1,3})/i.exec(ruleId ?? '')
if (!match) return 110
return Math.max(0, Math.min(255, Number(match[1])))
}
function parseProbability(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback
}
function stepOuterTotalistic2d(cells: Cells, settings: SceneParams) {
const rule = parseOuterTotalisticRule(settings.simulation?.ruleId ?? 'B3/S23')
const next = emptyCellsLike(cells)
const height = cells.length
const width = cells[0]?.length ?? 0
const boundary = resolveBoundaryCondition(settings.simulation?.grid)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let neighbours = 0
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx === 0 && dy === 0) continue
const rawX = x + dx
const rawY = y + dy
const nx = resolveBoundaryIndex(rawX, width, boundary)
const ny = resolveBoundaryIndex(rawY, height, boundary)
if (nx === null || ny === null) continue
if (cells[ny][nx]) neighbours += 1
}
}
next[y][x] = cells[y][x] ? rule.survival.has(neighbours) : rule.birth.has(neighbours)
}
}
return next
}
function stepElementary1d(cells: Cells, settings: SceneParams) {
const rule = parseElementaryRule(settings.simulation?.ruleId)
const next = cloneCells(cells)
const width = cells[0]?.length ?? 0
if (cells.length === 0 || width === 0) return next
const boundary = resolveBoundaryCondition(settings.simulation?.grid)
const sourceRowIndex = Math.max(0, cells.findIndex((row) => row.some(Boolean)))
const sourceRow = cells[sourceRowIndex] ?? []
for (let x = 0; x < width; x += 1) {
const leftIndex = resolveBoundaryIndex(x - 1, width, boundary)
const rightIndex = resolveBoundaryIndex(x + 1, width, boundary)
const left = leftIndex === null ? 0 : sourceRow[leftIndex] ? 1 : 0
const center = sourceRow[x] ? 1 : 0
const right = rightIndex === null ? 0 : sourceRow[rightIndex] ? 1 : 0
const pattern = (left << 2) | (center << 1) | right
next[sourceRowIndex][x] = ((rule >> pattern) & 1) === 1
}
return next
}
function deterministicCellNoise(seed: string, x: number, y: number) {
const random = seededRandom(`${seed}:${x}:${y}`)
return random()
}
function stepWildfire2d(cells: Cells, settings: SceneParams) {
const next = emptyCellsLike(cells)
const height = cells.length
const width = cells[0]?.length ?? 0
const boundary = resolveBoundaryCondition(settings.simulation?.grid)
const spreadProbability = parseProbability(settings.simulation?.spreadProbability, 1)
const seed = typeof settings.simulation?.seed === 'string' ? settings.simulation.seed : 'wildfire'
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (cells[y][x]) {
next[y][x] = false
continue
}
let burningNeighbours = 0
const offsets = [
[0, -1],
[1, 0],
[0, 1],
[-1, 0]
] as const
for (const [dx, dy] of offsets) {
const nx = resolveBoundaryIndex(x + dx, width, boundary)
const ny = resolveBoundaryIndex(y + dy, height, boundary)
if (nx === null || ny === null) continue
if (cells[ny][nx]) burningNeighbours += 1
}
next[y][x] = burningNeighbours > 0 && deterministicCellNoise(seed, x, y) < spreadProbability
}
}
return next
}
function stepNoop(cells: Cells) {
return cloneCells(cells)
}
export function registerCaEngineRuntime(engine: CaEngineRuntime, options: { replace?: boolean } = {}) { export function registerCaEngineRuntime(engine: CaEngineRuntime, options: { replace?: boolean } = {}) {
if (!engine.id.trim()) throw new Error('CA engine runtime id is required') if (!engine.id.trim()) throw new Error('CA engine runtime id is required')
if (engineRuntimes.has(engine.id) && !options.replace) { if (engineRuntimes.has(engine.id) && !options.replace) {
@@ -154,11 +34,16 @@ export function listCaEngineRuntimes() {
} }
export function stepCaCells(cells: Cells, settings: SceneParams) { export function stepCaCells(cells: Cells, settings: SceneParams) {
return stepCaSimulation(cells, settings).cells
}
export function stepCaSimulation(cells: Cells, settings: SceneParams): CaStepResult {
const engineId = settings.simulation?.engineId ?? 'game-of-life-2d' const engineId = settings.simulation?.engineId ?? 'game-of-life-2d'
const engine = engineRuntimes.get(engineId) ?? engineRuntimes.get('outer-totalistic-2d') ?? engineRuntimes.get('noop') const engine = engineRuntimes.get(engineId) ?? engineRuntimes.get('outer-totalistic-2d') ?? engineRuntimes.get('noop')
const next = engine ? engine.step(cells, settings) : stepNoop(cells) const nextSettings = structuredClone(settings) as SceneParams
const next = engine ? engine.step(cells, nextSettings) : stepNoop(cells)
assertSameCellsShape(cells, next, `CA engine output (${engine?.id ?? 'fallback'})`) assertSameCellsShape(cells, next, `CA engine output (${engine?.id ?? 'fallback'})`)
return next return { cells: next, settings: nextSettings }
} }
registerCaEngineRuntime( registerCaEngineRuntime(
@@ -205,6 +90,17 @@ registerCaEngineRuntime(
{ replace: true } { replace: true }
) )
registerCaEngineRuntime(
{
id: 'naga-3d',
label: 'Naga 3D',
supportedClasses: [{ dimensions: 3, states: 7 }],
defaultParams: { ruleId: 'naga-3d', rendererId: 'voxel-3d', seed: 'naga', edgeWrap: true },
step: stepNaga3d
},
{ replace: true }
)
registerCaEngineRuntime( registerCaEngineRuntime(
{ {
id: 'generic-voxel-ca', id: 'generic-voxel-ca',
@@ -1,4 +1,5 @@
import { numericGridSize, seededRandom } from './caRuntime.js' import { numericGridSize, seededRandom } from './caRuntime.js'
import { fVec, type NagaArrow } from './caEngineSteps/naga.js'
import type { CaClass, JsonObject, SceneParams } from './types.js' import type { CaClass, JsonObject, SceneParams } from './types.js'
export interface InitialConditionResult { export interface InitialConditionResult {
@@ -27,6 +28,14 @@ function clampDensity(value: unknown) {
return Math.max(0, Math.min(1, typeof value === 'number' ? value : 0.28)) return Math.max(0, Math.min(1, typeof value === 'number' ? value : 0.28))
} }
function clampInteger(value: unknown, fallback: number, min: number, max: number) {
return Math.max(min, Math.min(max, typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback))
}
function clampProbability(value: unknown, fallback: number) {
return Math.max(0, Math.min(1, typeof value === 'number' && Number.isFinite(value) ? value : fallback))
}
function randomSoup({ settings, caClass, params }: InitialConditionRuntimeInput): InitialConditionResult { function randomSoup({ settings, caClass, params }: InitialConditionRuntimeInput): InitialConditionResult {
const density = clampDensity(params.density) const density = clampDensity(params.density)
const seed = typeof params.seed === 'string' ? params.seed : 'studio-seed' const seed = typeof params.seed === 'string' ? params.seed : 'studio-seed'
@@ -53,6 +62,116 @@ function randomSoup({ settings, caClass, params }: InitialConditionRuntimeInput)
} }
} }
function randomNagaArrow(random: () => number): NagaArrow {
return (Math.floor(random() * 6) + 1) as NagaArrow
}
function nextNagaArrow(previous: NagaArrow, sameTypeProbability: number, random: () => number): NagaArrow {
if (random() < sameTypeProbability) return previous
const alternatives: NagaArrow[] = [1, 2, 3, 4, 5, 6].filter((arrow) => arrow !== previous) as NagaArrow[]
return alternatives[Math.floor(random() * alternatives.length)] ?? previous
}
function coordinateKey(x: number, y: number, z: number) {
return `${x}_${y}_${z}`
}
function inBounds(x: number, y: number, z: number, width: number, height: number, depth: number) {
return x >= 0 && y >= 0 && z >= 0 && x < width && y < height && z < depth
}
function nagaMarkov({ settings, caClass, params }: InitialConditionRuntimeInput): InitialConditionResult {
const [width, height, depth] = numericGridSize(settings.simulation?.grid, caClass.dimensions)
const count = clampInteger(params.count, 3, 1, Math.max(1, width * height * depth))
const length = clampInteger(params.length, 5, 1, Math.max(1, width * height * depth))
const sameTypeProbability = clampProbability(params.sameTypeProbability, 0.8)
const seed = typeof params.seed === 'string' ? params.seed : 'naga-markov'
const random = seededRandom(seed)
const occupied = new Set<string>()
const cells: number[][] = []
const maxAttempts = count * 300
let created = 0
let attempts = 0
while (created < count && attempts < maxAttempts) {
attempts += 1
let x = Math.floor(random() * width)
let y = Math.floor(random() * height)
let z = Math.floor(random() * depth)
let arrow = randomNagaArrow(random)
const proposed: number[][] = []
const proposedKeys = new Set<string>()
let valid = true
for (let index = 0; index < length; index += 1) {
const key = coordinateKey(x, y, z)
if (!inBounds(x, y, z, width, height, depth) || occupied.has(key) || proposedKeys.has(key)) {
valid = false
break
}
proposed.push([x, y, z, arrow])
proposedKeys.add(key)
const offset = fVec(arrow)
if (!offset) {
valid = false
break
}
const nextArrow = nextNagaArrow(arrow, sameTypeProbability, random)
x += offset[0]
y += offset[1]
z += offset[2]
arrow = nextArrow
}
const terminal = proposed[proposed.length - 1]
if (terminal) {
const [terminalX, terminalY, terminalZ, terminalArrow] = terminal
const terminalOffset = fVec(terminalArrow as NagaArrow)
if (!terminalOffset) {
valid = false
} else {
const targetX = terminalX + terminalOffset[0]
const targetY = terminalY + terminalOffset[1]
const targetZ = terminalZ + terminalOffset[2]
const targetKey = coordinateKey(targetX, targetY, targetZ)
if (
!inBounds(targetX, targetY, targetZ, width, height, depth) ||
occupied.has(targetKey) ||
proposedKeys.has(targetKey)
) {
valid = false
}
}
}
if (!valid) continue
for (const cell of proposed) {
const [cellX, cellY, cellZ] = cell
occupied.add(coordinateKey(cellX, cellY, cellZ))
cells.push(cell)
}
created += 1
}
return {
type: 'cells',
cells,
generator: {
kind: 'naga-markov',
count,
length,
sameTypeProbability,
seed,
created
}
}
}
export function registerInitialConditionRuntime( export function registerInitialConditionRuntime(
runtime: InitialConditionRuntime, runtime: InitialConditionRuntime,
options: { replace?: boolean } = {} options: { replace?: boolean } = {}
@@ -91,3 +210,12 @@ registerInitialConditionRuntime(
}, },
{ replace: true } { replace: true }
) )
registerInitialConditionRuntime(
{
id: 'naga-markov',
label: 'Naga Markov chains',
generate: nagaMarkov
},
{ replace: true }
)
+403 -84
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { stepCaCells } from './caEngines.js' import { stepCaCells, stepCaSimulation } from './caEngines.js'
import { generateInitialCondition } from './caInitialConditionGenerators.js' import { generateInitialCondition } from './caInitialConditionGenerators.js'
import { getBoundaryCondition, getCaClassFromParams, supportsCaClass as supportsRootClass } from './caRuntime.js' import { getBoundaryCondition, getCaClassFromParams, supportsCaClass as supportsRootClass } from './caRuntime.js'
import { CaRenderer, listCompatibleCaRendererRuntimes } from './CaRenderer.js' import { CaRenderer, listCompatibleCaRendererRuntimes } from './CaRenderer.js'
@@ -50,6 +50,9 @@ type EngineDimension = 1 | 2 | 3
type IcgDimension = 1 | 2 | 3 type IcgDimension = 1 | 2 | 3
const GALLERY_TABS: GalleryTab[] = ['decks', 'cas', 'engines', 'icgs'] const GALLERY_TABS: GalleryTab[] = ['decks', 'cas', 'engines', 'icgs']
const MIN_SPEED_LEVEL = 1
const MAX_SPEED_LEVEL = 10
const DEFAULT_SPEED_LEVEL = 5
interface RootConfig { interface RootConfig {
rootName: string rootName: string
@@ -201,6 +204,50 @@ function liveCount(cells: Cells) {
return cells.reduce((sum, row) => sum + row.reduce((rowSum, alive) => rowSum + (alive ? 1 : 0), 0), 0) return cells.reduce((sum, row) => sum + row.reduce((rowSum, alive) => rowSum + (alive ? 1 : 0), 0), 0)
} }
function isSparseVoxelClass(caClass: CaClass | undefined) {
return caClass?.dimensions === 3
}
function shouldProjectCellsIntoSettings(caClass: CaClass | undefined) {
return !isSparseVoxelClass(caClass)
}
function gridSizeForDimensions(dimensions: number) {
return [GRID_SIZE, dimensions >= 2 ? GRID_SIZE : 1, dimensions >= 3 ? GRID_SIZE : 1]
}
function defaultRendererForClass(caClass: CaClass) {
if (caClass.dimensions === 1) return 'elementary-1d'
if (caClass.dimensions === 3) return 'voxel-3d'
return '2d-canvas'
}
function cameraModeForDimensions(dimensions: number) {
return dimensions >= 3 ? '3d' : '2d'
}
function normalizeParamsForCaClass(params: JsonObject, caClass: CaClass, rendererId?: string): SceneParams {
const normalized = mergeParamObjects(params, {
caClass,
simulation: {
neighborhoodId: caClass.neighborhoodId,
grid: {
size: gridSizeForDimensions(caClass.dimensions),
boundary: 'wrap',
wrap: true
}
},
renderer: { id: rendererId || defaultRendererForClass(caClass) },
camera: { mode: cameraModeForDimensions(caClass.dimensions) }
}) as SceneParams
if (caClass.dimensions === 3 && normalized.simulation?.initialCondition?.cells?.length === 0) {
normalized.simulation.initialCondition = { type: 'cells', cells: [] }
}
return normalized
}
function isTypingTarget(target: EventTarget | null) { function isTypingTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false if (!(target instanceof HTMLElement)) return false
const tagName = target.tagName.toLowerCase() const tagName = target.tagName.toLowerCase()
@@ -324,6 +371,7 @@ function ruleLabel(ruleId: string) {
if (ruleId === 'generations-3d') return '3D Generations' if (ruleId === 'generations-3d') return '3D Generations'
if (ruleId === 'lattice-gas-3d') return '3D Lattice gas' if (ruleId === 'lattice-gas-3d') return '3D Lattice gas'
if (ruleId === 'snake-3d') return '3D Snake' if (ruleId === 'snake-3d') return '3D Snake'
if (ruleId === 'naga-3d') return '3D Naga'
return ruleId return ruleId
} }
@@ -428,11 +476,19 @@ function getParamPath(value: JsonObject, path: string[]) {
function engineParamPath(key: string) { function engineParamPath(key: string) {
if (key === 'rendererId') return ['renderer', 'id'] if (key === 'rendererId') return ['renderer', 'id']
if (key === 'ruleId') return ['simulation', 'ruleId'] if (key === 'ruleId') return ['simulation', 'ruleId']
if (key === 'edgeWrap') return ['simulation', 'grid', 'wrap']
if (key.startsWith('grid.')) return ['simulation', ...key.split('.')] if (key.startsWith('grid.')) return ['simulation', ...key.split('.')]
if (key.includes('.')) return key.split('.') if (key.includes('.')) return key.split('.')
return ['simulation', key] return ['simulation', key]
} }
function engineSettingValue(settings: SceneParams, engine: CaEngine, key: string, schema: JsonObject) {
if (key === 'edgeWrap') return getBoundaryCondition(settings) === 'wrap'
const path = engineParamPath(key)
return getParamPath(settings, path) ?? schemaDefault(key, schema, engine)
}
function schemaNumber(value: unknown, fallback: number) { function schemaNumber(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback return typeof value === 'number' && Number.isFinite(value) ? value : fallback
} }
@@ -471,6 +527,8 @@ function sceneAutoplay(scene: ResolvedScene | null) {
} }
function settingsWithCells(settings: SceneParams, cells: Cells): SceneParams { function settingsWithCells(settings: SceneParams, cells: Cells): SceneParams {
if (!shouldProjectCellsIntoSettings(getCaClassFromParams(settings))) return settings
return mergeParamObjects(settings, { return mergeParamObjects(settings, {
simulation: { simulation: {
initialCondition: { initialCondition: {
@@ -645,16 +703,87 @@ function formatTaskbarClock(date: Date) {
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
} }
function Win95WindowControls() { function WindowControls({
closeLabel = 'Close',
disabled = false,
onClose
}: {
closeLabel?: string
disabled?: boolean
onClose?: () => void
}) {
return ( return (
<span className="win95-window-controls" aria-hidden="true"> <span className={`win95-window-controls${onClose ? ' has-close' : ''}`} aria-hidden={onClose ? undefined : 'true'}>
<span>_</span> <span className="window-control-decor">_</span>
<span></span> <span className="window-control-decor"></span>
<span>×</span> {onClose ? (
<button
aria-label={closeLabel}
className="window-control-button window-close-button"
disabled={disabled}
title={closeLabel}
type="button"
onClick={onClose}
>
×
</button>
) : (
<span className="window-control-decor window-close-button">×</span>
)}
</span> </span>
) )
} }
function playbackIntervalMs(speedLevel: number) {
const clamped = Math.max(MIN_SPEED_LEVEL, Math.min(MAX_SPEED_LEVEL, speedLevel))
const slowMs = 720
const fastMs = 60
const t = (clamped - MIN_SPEED_LEVEL) / (MAX_SPEED_LEVEL - MIN_SPEED_LEVEL)
return Math.round(slowMs - (slowMs - fastMs) * t)
}
function speedStepsPerSecond(speedLevel: number) {
return Math.round((1000 / playbackIntervalMs(speedLevel)) * 10) / 10
}
function StudioSpeedKnob({
className = '',
speedLevel,
onChange
}: {
className?: string
speedLevel: number
onChange: (speedLevel: number) => void
}) {
const clamped = Math.max(MIN_SPEED_LEVEL, Math.min(MAX_SPEED_LEVEL, speedLevel))
const turn = (clamped - MIN_SPEED_LEVEL) / (MAX_SPEED_LEVEL - MIN_SPEED_LEVEL)
const angle = -132 + turn * 264
const stepsPerSecond = speedStepsPerSecond(clamped)
return (
<label
className={`studio-speed-knob ${className}`}
style={{ '--speed-knob-angle': `${angle}deg` } as React.CSSProperties}
title={`Simulation speed: ${stepsPerSecond} steps/s`}
>
<span className="speed-knob-label">Speed</span>
<span className="speed-knob-face" aria-hidden="true">
<span className="speed-knob-marker" />
</span>
<input
aria-label="Simulation speed"
max={MAX_SPEED_LEVEL}
min={MIN_SPEED_LEVEL}
step={1}
type="range"
value={clamped}
onChange={(event) => onChange(Number(event.target.value))}
/>
<span className="speed-knob-readout">{stepsPerSecond}x</span>
</label>
)
}
function DeckGallery({ initialTab }: { initialTab: GalleryTab }) { function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
const [activeTab, setActiveTab] = React.useState<GalleryTab>(initialTab) const [activeTab, setActiveTab] = React.useState<GalleryTab>(initialTab)
const [engineDimension, setEngineDimension] = React.useState<EngineDimension>(() => { const [engineDimension, setEngineDimension] = React.useState<EngineDimension>(() => {
@@ -984,7 +1113,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
</p> </p>
</div> </div>
<div className="status">{status}</div> <div className="status">{status}</div>
<Win95WindowControls /> <WindowControls />
</header> </header>
<nav aria-label="Gallery sections" className="gallery-tabs"> <nav aria-label="Gallery sections" className="gallery-tabs">
@@ -1387,8 +1516,7 @@ function GalleryCreationModal({
<h2>New {labels[activeTab]}</h2> <h2>New {labels[activeTab]}</h2>
</div> </div>
<div className="window-action-cluster"> <div className="window-action-cluster">
<Win95WindowControls /> <WindowControls onClose={onClose} />
<button type="button" onClick={onClose}>Close</button>
</div> </div>
</header> </header>
<div className="gallery-create-body"> <div className="gallery-create-body">
@@ -1621,8 +1749,7 @@ function EngineEditModal({
<h2>Edit {engine.name}</h2> <h2>Edit {engine.name}</h2>
</div> </div>
<div className="window-action-cluster"> <div className="window-action-cluster">
<Win95WindowControls /> <WindowControls disabled={saving} onClose={onClose} />
<button disabled={saving} type="button" onClick={onClose}>Close</button>
</div> </div>
</header> </header>
<div className="gallery-create-body"> <div className="gallery-create-body">
@@ -1687,11 +1814,26 @@ function InspectorEngineSettings({
if (!engine) return <p className="param-empty">Select a compatible engine to edit engine settings.</p> if (!engine) return <p className="param-empty">Select a compatible engine to edit engine settings.</p>
if (entries.length === 0) return <p className="param-empty">This engine does not expose editable settings.</p> if (entries.length === 0) return <p className="param-empty">This engine does not expose editable settings.</p>
const groupedEntries = entries.reduce<Array<{ name: string; entries: Array<[string, JsonObject]> }>>((groups, entry) => {
const groupName = typeof entry[1].group === 'string' ? entry[1].group : 'Engine settings'
const group = groups.find((candidate) => candidate.name === groupName)
if (group) {
group.entries.push(entry)
} else {
groups.push({ name: groupName, entries: [entry] })
}
return groups
}, [])
return ( return (
<div className="engine-settings-grid inspector-engine-settings"> <div className="inspector-engine-settings">
{entries.map(([key, schema]) => { {groupedEntries.map((group) => (
<fieldset className="engine-setting-group" key={group.name}>
<legend>{group.name}</legend>
<div className="engine-settings-grid">
{group.entries.map(([key, schema]) => {
const path = engineParamPath(key) const path = engineParamPath(key)
const value = getParamPath(settings, path) ?? schemaDefault(key, schema, engine) const value = engineSettingValue(settings, engine, key, schema)
return ( return (
<InspectorEngineSettingControl <InspectorEngineSettingControl
disabled={disabled} disabled={disabled}
@@ -1706,6 +1848,9 @@ function InspectorEngineSettings({
) )
})} })}
</div> </div>
</fieldset>
))}
</div>
) )
} }
@@ -1868,8 +2013,7 @@ function IcgEditModal({
<h2>Edit {generator.name}</h2> <h2>Edit {generator.name}</h2>
</div> </div>
<div className="window-action-cluster"> <div className="window-action-cluster">
<Win95WindowControls /> <WindowControls disabled={saving} onClose={onClose} />
<button disabled={saving} type="button" onClick={onClose}>Close</button>
</div> </div>
</header> </header>
<div className="gallery-create-body"> <div className="gallery-create-body">
@@ -1982,6 +2126,9 @@ function LibraryEditor({
const [selectedIcgId, setSelectedIcgId] = React.useState('') const [selectedIcgId, setSelectedIcgId] = React.useState('')
const [selectedEngineId, setSelectedEngineId] = React.useState('') const [selectedEngineId, setSelectedEngineId] = React.useState('')
const [icgDensity, setIcgDensity] = React.useState(0.28) const [icgDensity, setIcgDensity] = React.useState(0.28)
const [icgNagaCount, setIcgNagaCount] = React.useState(3)
const [icgNagaLength, setIcgNagaLength] = React.useState(5)
const [icgNagaSameTypeProbability, setIcgNagaSameTypeProbability] = React.useState(0.8)
const [icgSeed, setIcgSeed] = React.useState('studio-seed') const [icgSeed, setIcgSeed] = React.useState('studio-seed')
const [nodeName, setNodeName] = React.useState('Untitled preset') const [nodeName, setNodeName] = React.useState('Untitled preset')
const [nodeDescription, setNodeDescription] = React.useState('') const [nodeDescription, setNodeDescription] = React.useState('')
@@ -1993,8 +2140,10 @@ function LibraryEditor({
const [engineId, setEngineId] = React.useState('') const [engineId, setEngineId] = React.useState('')
const [boundaryCondition, setBoundaryCondition] = React.useState<BoundaryCondition>('wrap') const [boundaryCondition, setBoundaryCondition] = React.useState<BoundaryCondition>('wrap')
const [cells, setCells] = React.useState<Cells>(() => emptyCells()) const [cells, setCells] = React.useState<Cells>(() => emptyCells())
const [runtimeSettings, setRuntimeSettings] = React.useState<SceneParams | null>(null)
const [generation, setGeneration] = React.useState(0) const [generation, setGeneration] = React.useState(0)
const [running, setRunning] = React.useState(false) const [running, setRunning] = React.useState(false)
const [speedLevel, setSpeedLevel] = React.useState(DEFAULT_SPEED_LEVEL)
const [initialConditionDirty, setInitialConditionDirty] = React.useState(false) const [initialConditionDirty, setInitialConditionDirty] = React.useState(false)
const [initialConditionSaving, setInitialConditionSaving] = React.useState(false) const [initialConditionSaving, setInitialConditionSaving] = React.useState(false)
const [treeDrawerOpen, setTreeDrawerOpen] = React.useState( const [treeDrawerOpen, setTreeDrawerOpen] = React.useState(
@@ -2019,19 +2168,40 @@ function LibraryEditor({
? (mergeParamObjects( ? (mergeParamObjects(
mergeParamObjects(resolveParentParams(nodes, selectedNode), selectedNode.params), mergeParamObjects(resolveParentParams(nodes, selectedNode), selectedNode.params),
mergeParamObjects( mergeParamObjects(
createShotParams(ruleId, neighborhoodId, rendererId, cells, engineId, boundaryCondition), createShotParams(
ruleId,
neighborhoodId,
rendererId,
cells,
engineId,
boundaryCondition,
shouldProjectCellsIntoSettings(activeCaClass)
),
{ caClass: activeCaClass } { caClass: activeCaClass }
) )
) as SceneParams) ) as SceneParams)
: (mergeParamObjects( : (mergeParamObjects(
createShotParams(ruleId, neighborhoodId, rendererId, cells, engineId, boundaryCondition), createShotParams(
ruleId,
neighborhoodId,
rendererId,
cells,
engineId,
boundaryCondition,
shouldProjectCellsIntoSettings(activeCaClass)
),
{ caClass: activeCaClass } { caClass: activeCaClass }
) as SceneParams) ) as SceneParams)
const previewSettings = runtimeSettings ?? resolvedNodeSettings
const selectedNodeClass = getCaClassFromParams(resolvedNodeSettings) const selectedNodeClass = getCaClassFromParams(resolvedNodeSettings)
const compatibleIcgs = React.useMemo( const compatibleIcgs = React.useMemo(
() => icgs.filter((generator) => supportsRootClass(generator, selectedNodeClass)), () => icgs.filter((generator) => supportsRootClass(generator, selectedNodeClass)),
[icgs, selectedNodeClass] [icgs, selectedNodeClass]
) )
const selectedIcg = React.useMemo(
() => compatibleIcgs.find((generator) => generator.id === selectedIcgId) ?? null,
[compatibleIcgs, selectedIcgId]
)
const compatibleEngines = React.useMemo( const compatibleEngines = React.useMemo(
() => engines.filter((engine) => supportsRootClass(engine, selectedNodeClass)), () => engines.filter((engine) => supportsRootClass(engine, selectedNodeClass)),
[engines, selectedNodeClass] [engines, selectedNodeClass]
@@ -2125,11 +2295,15 @@ function LibraryEditor({
React.useEffect(() => { React.useEffect(() => {
if (!running) return if (!running) return
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
setCells((current) => stepCaCells(current, resolvedNodeSettings)) setCells((current) => {
const stepped = stepCaSimulation(current, previewSettings)
setRuntimeSettings(stepped.settings)
return stepped.cells
})
setGeneration((current) => current + 1) setGeneration((current) => current + 1)
}, 220) }, playbackIntervalMs(speedLevel))
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [ruleId, running]) }, [previewSettings, running, speedLevel])
React.useEffect(() => { React.useEffect(() => {
if (!selectedNode || editorRevision === 0 || hydratedRef.current) return if (!selectedNode || editorRevision === 0 || hydratedRef.current) return
@@ -2204,6 +2378,7 @@ function LibraryEditor({
api<ResolvedNode>(`/api/ca/preset-trees/${treeId}/nodes/${nodeId}/resolved`) api<ResolvedNode>(`/api/ca/preset-trees/${treeId}/nodes/${nodeId}/resolved`)
]) ])
setSelectedNodeId(node.id) setSelectedNodeId(node.id)
setRuntimeSettings(null)
setInitialConditionDirty(false) setInitialConditionDirty(false)
setNodeName(node.name) setNodeName(node.name)
setNodeDescription(node.description ?? '') setNodeDescription(node.description ?? '')
@@ -2446,57 +2621,83 @@ function LibraryEditor({
setStatus(`Unset ${path.join('.')} on "${node.name}"`) setStatus(`Unset ${path.join('.')} on "${node.name}"`)
} }
async function applyRandomNoiseIcg() { function applyIcgDefaults(generator: InitialConditionGenerator | undefined) {
if (typeof generator?.default_params.density === 'number') setIcgDensity(generator.default_params.density)
if (typeof generator?.default_params.count === 'number') setIcgNagaCount(generator.default_params.count)
if (typeof generator?.default_params.length === 'number') setIcgNagaLength(generator.default_params.length)
if (typeof generator?.default_params.sameTypeProbability === 'number') {
setIcgNagaSameTypeProbability(generator.default_params.sameTypeProbability)
}
if (typeof generator?.default_params.seed === 'string') setIcgSeed(generator.default_params.seed)
}
function selectedIcgParams(generator: InitialConditionGenerator) {
if (generator.generator_kind === 'naga-markov') {
return {
count: icgNagaCount,
length: icgNagaLength,
sameTypeProbability: icgNagaSameTypeProbability,
seed: icgSeed
}
}
return { density: Math.max(0, Math.min(1, icgDensity)), seed: icgSeed }
}
function applySelectedIcg() {
if (!selectedNode || !selectedNodeClass) return if (!selectedNode || !selectedNodeClass) return
const generator = compatibleIcgs.find((item) => item.id === selectedIcgId) const generator = compatibleIcgs.find((item) => item.id === selectedIcgId)
if (!generator) return if (!generator) return
try { try {
setRunning(false) setRunning(false)
const density = Math.max(0, Math.min(1, icgDensity))
const initialCondition = generateInitialCondition(generator.generator_kind, { const initialCondition = generateInitialCondition(generator.generator_kind, {
settings: resolvedNodeSettings, settings: resolvedNodeSettings,
caClass: selectedNodeClass, caClass: selectedNodeClass,
params: { density, seed: icgSeed } params: selectedIcgParams(generator)
}) })
const inheritedParams = resolveParentParams(nodes, selectedNode) const params = mergeParamObjects(selectedNode.params, { simulation: { initialCondition } }) as SceneParams
const params = pruneInheritedParams( setNodes((current) =>
mergeParamObjects(selectedNode.params, { simulation: { initialCondition } }), current.map((node) => (node.id === selectedNode.id ? { ...node, params } : node))
inheritedParams )
) as SceneParams setRuntimeSettings(
const updated = await api<PresetNode>(`/api/ca/preset-trees/${selectedNode.tree_id}/nodes/${selectedNode.id}`, { mergeParamObjects(resolvedNodeSettings, {
method: 'PATCH', simulation: { initialCondition }
body: JSON.stringify({ params }) }) as SceneParams
}) )
setNodes((current) => current.map((node) => (node.id === updated.id ? updated : node)))
setCells(coordinatesToCells(initialCondition.cells)) setCells(coordinatesToCells(initialCondition.cells))
setInitialConditionDirty(false) setInitialConditionDirty(true)
setGeneration(0) setGeneration(0)
setStatus(`Generated initial condition on "${selectedNode.name}"`) setStatus(`Generated unsaved initial condition on "${selectedNode.name}"`)
} catch (error) { } catch (error) {
setStatus(error instanceof Error ? error.message : 'ICG apply failed') setStatus(error instanceof Error ? error.message : 'ICG apply failed')
} }
} }
function applyEngine(nextEngineId: string) { async function applyEngine(nextEngineId: string) {
const engine = compatibleEngines.find((item) => item.id === nextEngineId) const engine = compatibleEngines.find((item) => item.id === nextEngineId)
setSelectedEngineId(nextEngineId) setSelectedEngineId(nextEngineId)
if (!engine) { if (!engine) {
setEngineId('') setEngineId('')
setRuntimeSettings(null)
if (selectedNode) { if (selectedNode) {
setNodes((current) => try {
current.map((node) => const params = setParamPath(selectedNode.params, ['simulation', 'engineId'], null)
node.id === selectedNode.id const updated = await api<PresetNode>(`/api/ca/preset-trees/${selectedNode.tree_id}/nodes/${selectedNode.id}`, {
? { ...node, params: setParamPath(node.params, ['simulation', 'engineId'], null) } method: 'PATCH',
: node body: JSON.stringify({ params })
) })
) setNodes((current) => current.map((node) => (node.id === updated.id ? updated : node)))
markEdited() setStatus(`Unset evolution function on "${selectedNode.name}"`)
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Engine update failed')
}
} }
return return
} }
setEngineId(engine.engine_kind) setEngineId(engine.engine_kind)
setRuntimeSettings(null)
if (typeof engine.default_params.ruleId === 'string') setRuleId(engine.default_params.ruleId) if (typeof engine.default_params.ruleId === 'string') setRuleId(engine.default_params.ruleId)
if ( if (
typeof engine.default_params.rendererId === 'string' && typeof engine.default_params.rendererId === 'string' &&
@@ -2505,15 +2706,25 @@ function LibraryEditor({
setRendererId(engine.default_params.rendererId) setRendererId(engine.default_params.rendererId)
} }
if (selectedNode) { if (selectedNode) {
void setNodeParam(selectedNode, ['simulation', 'engineId'], engine.engine_kind) try {
let params = setParamPath(selectedNode.params, ['simulation', 'engineId'], engine.engine_kind)
for (const [key, value] of Object.entries(engine.default_params)) { for (const [key, value] of Object.entries(engine.default_params)) {
void setNodeParam(selectedNode, engineParamPath(key), value) params = setParamPath(params, engineParamPath(key), value)
}
params = normalizeParamsForCaClass(params, activeCaClass, engine.default_params.rendererId as string | undefined)
const updated = await api<PresetNode>(`/api/ca/preset-trees/${selectedNode.tree_id}/nodes/${selectedNode.id}`, {
method: 'PATCH',
body: JSON.stringify({ params })
})
setNodes((current) => current.map((node) => (node.id === updated.id ? updated : node)))
setStatus(`Selected "${engine.name}" for "${selectedNode.name}"`)
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Engine update failed')
} }
} }
markEdited()
} }
function applyEngineSetting(path: string[], value: unknown) { async function applyEngineSetting(path: string[], value: unknown) {
if (!selectedNode) return if (!selectedNode) return
const joinedPath = path.join('.') const joinedPath = path.join('.')
if (joinedPath === 'simulation.ruleId' && typeof value === 'string') setRuleId(value) if (joinedPath === 'simulation.ruleId' && typeof value === 'string') setRuleId(value)
@@ -2523,7 +2734,22 @@ function LibraryEditor({
} }
if (joinedPath === 'simulation.grid.wrap' && typeof value === 'boolean') { if (joinedPath === 'simulation.grid.wrap' && typeof value === 'boolean') {
setBoundaryCondition(value ? 'wrap' : 'fixed') setBoundaryCondition(value ? 'wrap' : 'fixed')
setRuntimeSettings(null)
const params = setParamPath(
setParamPath(selectedNode.params, ['simulation', 'grid', 'boundary'], value ? 'wrap' : 'fixed'),
['simulation', 'grid', 'wrap'],
value
)
const updated = await api<PresetNode>(`/api/ca/preset-trees/${selectedNode.tree_id}/nodes/${selectedNode.id}`, {
method: 'PATCH',
body: JSON.stringify({ params })
})
setNodes((current) => current.map((node) => (node.id === updated.id ? updated : node)))
await loadNodeById(selectedNode.id)
setStatus(`Saved edge wrap on "${selectedNode.name}"`)
return
} }
setRuntimeSettings(null)
void setNodeParam(selectedNode, path, value) void setNodeParam(selectedNode, path, value)
} }
@@ -2567,14 +2793,15 @@ function LibraryEditor({
setDimensions(nextDimensions) setDimensions(nextDimensions)
setStateCount(nextStateCount) setStateCount(nextStateCount)
setNeighborhoodId(nextNeighborhoodId) setNeighborhoodId(nextNeighborhoodId)
setRuntimeSettings(null)
setCompatibilityWarning(warnings.join('. ')) setCompatibilityWarning(warnings.join('. '))
setNodes((current) => setNodes((current) =>
current.map((node) => { current.map((node) => {
if (node.id !== selectedNode.id) return node if (node.id !== selectedNode.id) return node
let params = setParamPath(node.params, ['caClass'], nextClass) let params = normalizeParamsForCaClass(node.params, nextClass, nextRendererId)
params = setParamPath(params, ['simulation', 'neighborhoodId'], nextNeighborhoodId) params = setParamPath(params, ['simulation', 'neighborhoodId'], nextNeighborhoodId)
if (unsetEngine) params = setParamPath(params, ['simulation', 'engineId'], null) if (unsetEngine) params = setParamPath(params, ['simulation', 'engineId'], null)
if (nextRendererId !== rendererId) params = setParamPath(params, ['renderer', 'id'], nextRendererId) params = setParamPath(params, ['renderer', 'id'], nextRendererId)
return { ...node, params } return { ...node, params }
}) })
) )
@@ -2584,10 +2811,40 @@ function LibraryEditor({
function updateCells(next: Cells) { function updateCells(next: Cells) {
setRunning(false) setRunning(false)
setGeneration(0) setGeneration(0)
setRuntimeSettings(null)
setCells(next) setCells(next)
setInitialConditionDirty(true) setInitialConditionDirty(true)
} }
function clearInitialCondition() {
if (!selectedNode) return
setRunning(false)
setGeneration(0)
setCells(emptyCells())
const initialCondition = { type: 'cells', cells: [] }
const params = mergeParamObjects(selectedNode.params, {
simulation: { initialCondition }
}) as SceneParams
setNodes((current) =>
current.map((node) => (node.id === selectedNode.id ? { ...node, params } : node))
)
setRuntimeSettings(
mergeParamObjects(resolvedNodeSettings, {
simulation: { initialCondition }
}) as SceneParams
)
if (isSparseVoxelClass(activeCaClass)) {
setStatus(`Cleared unsaved voxel initial condition on "${selectedNode.name}"`)
} else {
setStatus(`Cleared unsaved initial condition on "${selectedNode.name}"`)
}
setInitialConditionDirty(true)
}
async function saveInitialCondition() { async function saveInitialCondition() {
if (!selectedNode || initialConditionSaving) return if (!selectedNode || initialConditionSaving) return
@@ -2595,12 +2852,15 @@ function LibraryEditor({
setInitialConditionSaving(true) setInitialConditionSaving(true)
setRunning(false) setRunning(false)
const inheritedParams = resolveParentParams(nodes, selectedNode) const inheritedParams = resolveParentParams(nodes, selectedNode)
const sparseCells = isSparseVoxelClass(activeCaClass)
? previewSettings.simulation?.initialCondition?.cells ?? resolvedNodeSettings.simulation?.initialCondition?.cells ?? []
: cellsToCoordinates(cells)
const params = pruneInheritedParams( const params = pruneInheritedParams(
mergeParamObjects(selectedNode.params, { mergeParamObjects(selectedNode.params, {
simulation: { simulation: {
initialCondition: { initialCondition: {
type: 'cells', type: 'cells',
cells: cellsToCoordinates(cells) cells: sparseCells
} }
} }
}), }),
@@ -2611,6 +2871,7 @@ function LibraryEditor({
body: JSON.stringify({ params }) body: JSON.stringify({ params })
}) })
setNodes((current) => current.map((node) => (node.id === updated.id ? updated : node))) setNodes((current) => current.map((node) => (node.id === updated.id ? updated : node)))
setRuntimeSettings(null)
setInitialConditionDirty(false) setInitialConditionDirty(false)
setGeneration(0) setGeneration(0)
setStatus(`Saved initial condition on "${selectedNode.name}"`) setStatus(`Saved initial condition on "${selectedNode.name}"`)
@@ -2623,12 +2884,16 @@ function LibraryEditor({
function previewStep() { function previewStep() {
setRunning(false) setRunning(false)
setCells((current) => stepCaCells(current, resolvedNodeSettings)) setCells((current) => {
const stepped = stepCaSimulation(current, previewSettings)
setRuntimeSettings(stepped.settings)
return stepped.cells
})
setGeneration((current) => current + 1) setGeneration((current) => current + 1)
} }
return ( return (
<main className="studio-shell preset-studio-shell"> <main className={`studio-shell preset-studio-shell${inspectorOpen ? ' inspector-open' : ''}`}>
<header className="topbar preset-studio-topbar"> <header className="topbar preset-studio-topbar">
<div className="preset-library-heading"> <div className="preset-library-heading">
<p className="eyebrow">Preset Library</p> <p className="eyebrow">Preset Library</p>
@@ -2637,9 +2902,12 @@ function LibraryEditor({
Back Back
</button> </button>
</div> </div>
<div className="topbar-speed-float" aria-label="Simulation speed">
<StudioSpeedKnob speedLevel={speedLevel} onChange={setSpeedLevel} />
</div>
<div className="topbar-actions"> <div className="topbar-actions">
<div className="status">{status}</div> <div className="status">{status}</div>
<Win95WindowControls /> <WindowControls />
</div> </div>
</header> </header>
@@ -2658,10 +2926,7 @@ function LibraryEditor({
<p className="eyebrow">Presets</p> <p className="eyebrow">Presets</p>
<h2>Library tree</h2> <h2>Library tree</h2>
</div> </div>
<button aria-label="Close preset drawer" className="icon-button drawer-close" type="button" onClick={() => setTreeDrawerOpen(false)}> <WindowControls closeLabel="Close preset drawer" onClose={() => setTreeDrawerOpen(false)} />
×
</button>
<Win95WindowControls />
</div> </div>
<div className="drawer-scroll"> <div className="drawer-scroll">
<div className="root-selector"> <div className="root-selector">
@@ -2727,7 +2992,7 @@ function LibraryEditor({
</button> </button>
</div> </div>
) : null} ) : null}
<CaRenderer caption="" cells={cells} settings={resolvedNodeSettings} onCellsChange={updateCells} /> <CaRenderer caption="" cells={cells} settings={previewSettings} onCellsChange={updateCells} />
</div> </div>
<footer className="metrics preset-stage-metrics"> <footer className="metrics preset-stage-metrics">
@@ -2749,10 +3014,7 @@ function LibraryEditor({
<p className="eyebrow">Inspector</p> <p className="eyebrow">Inspector</p>
<h2>{selectedNode ? nodeName : 'No preset selected'}</h2> <h2>{selectedNode ? nodeName : 'No preset selected'}</h2>
</div> </div>
<button aria-label="Close inspector" className="icon-button drawer-close" type="button" onClick={() => setInspectorOpen(false)}> <WindowControls closeLabel="Close inspector" onClose={() => setInspectorOpen(false)} />
×
</button>
<Win95WindowControls />
</div> </div>
<div className="inspector-tabs" role="tablist" aria-label="Preset inspector sections"> <div className="inspector-tabs" role="tablist" aria-label="Preset inspector sections">
<button className={inspectorTab === 'ca' ? 'active' : ''} role="tab" type="button" onClick={() => setInspectorTab('ca')}>CA</button> <button className={inspectorTab === 'ca' ? 'active' : ''} role="tab" type="button" onClick={() => setInspectorTab('ca')}>CA</button>
@@ -2872,7 +3134,7 @@ function LibraryEditor({
{inspectorTab === 'initial' ? ( {inspectorTab === 'initial' ? (
<div className="inspector-section"> <div className="inspector-section">
<div className="button-row initial-condition-actions"> <div className="button-row initial-condition-actions">
<button type="button" onClick={() => updateCells(emptyCells())}>Clear canvas</button> <button type="button" onClick={clearInitialCondition}>Clear canvas</button>
<button <button
className="primary" className="primary"
disabled={!selectedNode || !initialConditionDirty || initialConditionSaving} disabled={!selectedNode || !initialConditionDirty || initialConditionSaving}
@@ -2894,13 +3156,36 @@ function LibraryEditor({
<select value={selectedIcgId} onChange={(event) => { <select value={selectedIcgId} onChange={(event) => {
const generator = compatibleIcgs.find((item) => item.id === event.target.value) const generator = compatibleIcgs.find((item) => item.id === event.target.value)
setSelectedIcgId(event.target.value) setSelectedIcgId(event.target.value)
if (typeof generator?.default_params.density === 'number') setIcgDensity(generator.default_params.density) applyIcgDefaults(generator)
if (typeof generator?.default_params.seed === 'string') setIcgSeed(generator.default_params.seed)
}}> }}>
<option value="">Select generator</option> <option value="">Select generator</option>
{compatibleIcgs.map((generator) => <option key={generator.id} value={generator.id}>{generator.name}</option>)} {compatibleIcgs.map((generator) => <option key={generator.id} value={generator.id}>{generator.name}</option>)}
</select> </select>
</label> </label>
{selectedIcg?.generator_kind === 'naga-markov' ? (
<>
<div className="root-config-grid">
<label>
Length
<input min={1} step={1} type="number" value={icgNagaLength} onChange={(event) => setIcgNagaLength(Math.max(1, Number(event.target.value) || 1))} />
</label>
</div>
<div className="icg-controls">
<label>
Naga count
<input max={16} min={1} step={1} type="range" value={icgNagaCount} onChange={(event) => setIcgNagaCount(Math.max(1, Number(event.target.value) || 1))} />
</label>
<strong>{icgNagaCount}</strong>
</div>
<div className="icg-controls">
<label>
Same type probability
<input max={1} min={0} step={0.01} type="range" value={icgNagaSameTypeProbability} onChange={(event) => setIcgNagaSameTypeProbability(Number(event.target.value))} />
</label>
<strong>{Math.round(icgNagaSameTypeProbability * 100)}%</strong>
</div>
</>
) : (
<div className="icg-controls"> <div className="icg-controls">
<label> <label>
Density Density
@@ -2908,11 +3193,12 @@ function LibraryEditor({
</label> </label>
<strong>{Math.round(icgDensity * 100)}%</strong> <strong>{Math.round(icgDensity * 100)}%</strong>
</div> </div>
)}
<label> <label>
Seed Seed
<input value={icgSeed} onChange={(event) => setIcgSeed(event.target.value)} /> <input value={icgSeed} onChange={(event) => setIcgSeed(event.target.value)} />
</label> </label>
<button className="primary" disabled={!selectedNode || !selectedIcgId} type="button" onClick={() => void applyRandomNoiseIcg()}> <button className="primary" disabled={!selectedNode || !selectedIcgId} type="button" onClick={applySelectedIcg}>
Generate initial condition Generate initial condition
</button> </button>
</> </>
@@ -2979,9 +3265,7 @@ function SlideMetadataModal({
<p className="eyebrow">Slide Metadata</p> <p className="eyebrow">Slide Metadata</p>
<h2>{scene.scene.title}</h2> <h2>{scene.scene.title}</h2>
</div> </div>
<button type="button" onClick={onClose}> <WindowControls onClose={onClose} />
Close
</button>
</header> </header>
<div className="slide-metadata-body"> <div className="slide-metadata-body">
<label> <label>
@@ -3075,7 +3359,7 @@ function CaAssetPickerModal({
<p className="eyebrow">CA Assets</p> <p className="eyebrow">CA Assets</p>
<h2>Select a preset</h2> <h2>Select a preset</h2>
</div> </div>
<button type="button" onClick={onClose}>Close</button> <WindowControls onClose={onClose} />
</header> </header>
<div className="ca-asset-layout"> <div className="ca-asset-layout">
<aside className="ca-asset-browser"> <aside className="ca-asset-browser">
@@ -3155,6 +3439,7 @@ function DeckViewer({ deckId }: { deckId: string }) {
const [cells, setCells] = React.useState<Cells>(() => emptyCells()) const [cells, setCells] = React.useState<Cells>(() => emptyCells())
const [generation, setGeneration] = React.useState(0) const [generation, setGeneration] = React.useState(0)
const [running, setRunning] = React.useState(false) const [running, setRunning] = React.useState(false)
const [speedLevel, setSpeedLevel] = React.useState(DEFAULT_SPEED_LEVEL)
const [status, setStatus] = React.useState('Loading viewer...') const [status, setStatus] = React.useState('Loading viewer...')
const settingsRef = React.useRef(settings) const settingsRef = React.useRef(settings)
const sceneIndexRef = React.useRef(sceneIndex) const sceneIndexRef = React.useRef(sceneIndex)
@@ -3197,7 +3482,12 @@ function DeckViewer({ deckId }: { deckId: string }) {
) )
const stepOnce = React.useCallback(() => { const stepOnce = React.useCallback(() => {
setCells((current) => stepCaCells(current, settingsWithCells(settingsRef.current, current))) setCells((current) => {
const stepped = stepCaSimulation(current, settingsWithCells(settingsRef.current, current))
setSettings(stepped.settings)
settingsRef.current = stepped.settings
return stepped.cells
})
setGeneration((current) => current + 1) setGeneration((current) => current + 1)
}, []) }, [])
@@ -3228,9 +3518,9 @@ function DeckViewer({ deckId }: { deckId: string }) {
React.useEffect(() => { React.useEffect(() => {
if (!running) return if (!running) return
const timer = window.setInterval(stepOnce, 220) const timer = window.setInterval(stepOnce, playbackIntervalMs(speedLevel))
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [running, stepOnce]) }, [running, speedLevel, stepOnce])
React.useEffect(() => { React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
@@ -3275,6 +3565,9 @@ function DeckViewer({ deckId }: { deckId: string }) {
<main className="viewer-shell"> <main className="viewer-shell">
<section className="viewer-stage" aria-label={activeScene?.scene.title ?? status}> <section className="viewer-stage" aria-label={activeScene?.scene.title ?? status}>
<CaRenderer caption={sceneCaption(activeScene)} cells={cells} settings={liveSettings} onCellsChange={() => undefined} /> <CaRenderer caption={sceneCaption(activeScene)} cells={cells} settings={liveSettings} onCellsChange={() => undefined} />
<div className="viewer-speed-float" aria-label="Simulation speed">
<StudioSpeedKnob speedLevel={speedLevel} onChange={setSpeedLevel} />
</div>
<div className="viewer-rec-status" aria-hidden="true"> <div className="viewer-rec-status" aria-hidden="true">
<span>{resolvedDeck?.deck.title ?? 'CA Deck'}</span> <span>{resolvedDeck?.deck.title ?? 'CA Deck'}</span>
<span>{activeScene ? `${sceneIndex + 1}/${resolvedDeck?.scenes.length ?? 0}` : status}</span> <span>{activeScene ? `${sceneIndex + 1}/${resolvedDeck?.scenes.length ?? 0}` : status}</span>
@@ -3310,8 +3603,10 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
const [ruleId, setRuleId] = React.useState('B3/S23') const [ruleId, setRuleId] = React.useState('B3/S23')
const [boundaryCondition, setBoundaryCondition] = React.useState<BoundaryCondition>('wrap') const [boundaryCondition, setBoundaryCondition] = React.useState<BoundaryCondition>('wrap')
const [cells, setCells] = React.useState<Cells>(() => placeGliderCells()) const [cells, setCells] = React.useState<Cells>(() => placeGliderCells())
const [runtimeSettings, setRuntimeSettings] = React.useState<SceneParams | null>(null)
const [generation, setGeneration] = React.useState(0) const [generation, setGeneration] = React.useState(0)
const [running, setRunning] = React.useState(false) const [running, setRunning] = React.useState(false)
const [speedLevel, setSpeedLevel] = React.useState(DEFAULT_SPEED_LEVEL)
const [status, setStatus] = React.useState('Loading deck...') const [status, setStatus] = React.useState('Loading deck...')
const hydratedRef = React.useRef(false) const hydratedRef = React.useRef(false)
const deckTitleHydratedRef = React.useRef(false) const deckTitleHydratedRef = React.useRef(false)
@@ -3325,12 +3620,25 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null
const assignedNodeId = activeScene?.scene.preset_node_id const assignedNodeId = activeScene?.scene.preset_node_id
const pickerSelectedNode = pickerNodes.find((node) => node.id === pickerSelectedNodeId) ?? null const pickerSelectedNode = pickerNodes.find((node) => node.id === pickerSelectedNodeId) ?? null
const selectedNodeBaseSettings = selectedNode
? (mergeParamObjects(resolveParentParams(nodes, selectedNode), selectedNode.params) as SceneParams)
: null
const selectedNodeBaseClass = getCaClassFromParams(selectedNodeBaseSettings ?? undefined)
const resolvedNodeSettings = selectedNode const resolvedNodeSettings = selectedNode
? (mergeParamObjects( ? (mergeParamObjects(
mergeParamObjects(resolveParentParams(nodes, selectedNode), selectedNode.params), selectedNodeBaseSettings ?? {},
createShotParams(ruleId, neighborhoodId, rendererId, cells, undefined, boundaryCondition) createShotParams(
ruleId,
neighborhoodId,
rendererId,
cells,
undefined,
boundaryCondition,
shouldProjectCellsIntoSettings(selectedNodeBaseClass)
)
) as SceneParams) ) as SceneParams)
: createShotParams(ruleId, neighborhoodId, rendererId, cells, undefined, boundaryCondition) : createShotParams(ruleId, neighborhoodId, rendererId, cells, undefined, boundaryCondition)
const previewSettings = runtimeSettings ?? resolvedNodeSettings
const refreshDeck = React.useCallback(async () => { const refreshDeck = React.useCallback(async () => {
const loadedDeck = await api<Deck>(`/api/ca/decks/${deckId}`) const loadedDeck = await api<Deck>(`/api/ca/decks/${deckId}`)
@@ -3419,11 +3727,15 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
React.useEffect(() => { React.useEffect(() => {
if (!running) return if (!running) return
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
setCells((current) => stepCaCells(current, resolvedNodeSettings)) setCells((current) => {
const stepped = stepCaSimulation(current, previewSettings)
setRuntimeSettings(stepped.settings)
return stepped.cells
})
setGeneration((current) => current + 1) setGeneration((current) => current + 1)
}, 220) }, playbackIntervalMs(speedLevel))
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [ruleId, running]) }, [previewSettings, running, speedLevel])
React.useEffect(() => { React.useEffect(() => {
if (!deck || deckTitleHydratedRef.current || deckTitle === deck.title) return if (!deck || deckTitleHydratedRef.current || deckTitle === deck.title) return
@@ -3504,6 +3816,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
]) ])
setNodes(treeNodes) setNodes(treeNodes)
setSelectedNodeId(nodeId) setSelectedNodeId(nodeId)
setRuntimeSettings(null)
setSceneName(node.name || fallbackName || 'Untitled node') setSceneName(node.name || fallbackName || 'Untitled node')
setRendererId(resolved.params.renderer?.id ?? '2d-canvas') setRendererId(resolved.params.renderer?.id ?? '2d-canvas')
setNeighborhoodId(resolved.params.simulation?.neighborhoodId ?? 'moore') setNeighborhoodId(resolved.params.simulation?.neighborhoodId ?? 'moore')
@@ -3527,10 +3840,12 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
setCaption(typeof scene.scene.params.caption === 'string' ? scene.scene.params.caption : '') setCaption(typeof scene.scene.params.caption === 'string' ? scene.scene.params.caption : '')
setAutoplayOnSlideChange(shouldAutoplay) setAutoplayOnSlideChange(shouldAutoplay)
if (scene.scene.preset_tree_id && scene.scene.preset_node_id) { if (scene.scene.preset_tree_id && scene.scene.preset_node_id) {
setRuntimeSettings(null)
void loadNodeById(scene.scene.preset_tree_id, scene.scene.preset_node_id, scene.scene.title, { runAfterLoad: shouldAutoplay }) void loadNodeById(scene.scene.preset_tree_id, scene.scene.preset_node_id, scene.scene.title, { runAfterLoad: shouldAutoplay })
return return
} }
setSelectedNodeId('') setSelectedNodeId('')
setRuntimeSettings(null)
setSceneName('No CA preset') setSceneName('No CA preset')
setCells(emptyCells()) setCells(emptyCells())
setGeneration(0) setGeneration(0)
@@ -3708,7 +4023,11 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
function previewStep() { function previewStep() {
setRunning(false) setRunning(false)
setCells((current) => stepCaCells(current, resolvedNodeSettings)) setCells((current) => {
const stepped = stepCaSimulation(current, previewSettings)
setRuntimeSettings(stepped.settings)
return stepped.cells
})
setGeneration((current) => current + 1) setGeneration((current) => current + 1)
} }
@@ -3785,7 +4104,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
<div className="status">{status}</div> <div className="status">{status}</div>
<Win95WindowControls /> <WindowControls />
</div> </div>
</header> </header>
@@ -3841,7 +4160,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
<h2>{activeScene ? slideTitle : sceneName}</h2> <h2>{activeScene ? slideTitle : sceneName}</h2>
</div> </div>
<div className="button-row"> <div className="button-row">
<Win95WindowControls /> <WindowControls />
<button <button
aria-label="Previous slide" aria-label="Previous slide"
className="deck-nav-button" className="deck-nav-button"
@@ -3886,7 +4205,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
<CaRenderer <CaRenderer
caption={activeScene ? caption : ''} caption={activeScene ? caption : ''}
cells={cells} cells={cells}
settings={resolvedNodeSettings} settings={previewSettings}
onCellsChange={() => undefined} onCellsChange={() => undefined}
/> />
) : ( ) : (
+250 -4
View File
@@ -451,6 +451,35 @@ label {
gap: 8px; gap: 8px;
} }
.win95-window-controls.has-close {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex: 0 0 auto;
}
.window-control-decor {
display: none;
}
.window-control-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
min-width: 34px;
height: 34px;
min-height: 34px;
padding: 0;
border-radius: 999px;
line-height: 1;
}
.window-close-button {
font-size: 1.05rem;
font-weight: 700;
}
.toggle-field { .toggle-field {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -491,6 +520,43 @@ label {
.topbar-actions { .topbar-actions {
justify-content: flex-end; justify-content: flex-end;
flex: 0 0 auto;
min-width: 0;
}
.topbar-actions .status {
min-width: 160px;
text-align: right;
}
.topbar-speed-float {
position: absolute;
top: 12px;
right: 0;
z-index: 12;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border: 1px solid color-mix(in srgb, var(--border), transparent 12%);
border-radius: var(--radius);
background: color-mix(in srgb, var(--panel-soft), transparent 18%);
transition: opacity var(--transition), transform var(--transition), visibility var(--transition);
}
.topbar-speed-float .studio-speed-knob {
--speed-knob-size: 68px;
}
.topbar-speed-float .speed-knob-face {
width: 46px;
height: 46px;
}
.topbar-speed-float .studio-speed-knob input {
inset: 16px 11px 14px;
width: 46px;
height: 46px;
} }
.deck-title-editor { .deck-title-editor {
@@ -617,12 +683,17 @@ h2 {
margin-left: auto; margin-left: auto;
} }
:root[data-skin="windows-95"] .win95-window-controls span { :root[data-skin="windows-95"] .win95-window-controls .window-control-decor,
:root[data-skin="windows-95"] .win95-window-controls .window-control-button {
display: grid; display: grid;
place-items: center; place-items: center;
width: 18px; width: 18px;
min-width: 18px;
height: 17px; height: 17px;
min-height: 17px;
padding: 0;
border: 2px solid; border: 2px solid;
border-radius: 0;
border-color: #ffffff #404040 #404040 #ffffff; border-color: #ffffff #404040 #404040 #ffffff;
background: #c0c0c0; background: #c0c0c0;
color: #000000; color: #000000;
@@ -1157,6 +1228,7 @@ h2 {
} }
.preset-studio-shell { .preset-studio-shell {
position: relative;
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
height: calc(100dvh - 73px); height: calc(100dvh - 73px);
@@ -1165,6 +1237,13 @@ h2 {
overflow: hidden; overflow: hidden;
} }
.preset-studio-shell.inspector-open .topbar-speed-float {
visibility: hidden;
opacity: 0;
pointer-events: none;
transform: translateX(10px);
}
.admin-app:has(.preset-studio-shell) { .admin-app:has(.preset-studio-shell) {
height: 100dvh; height: 100dvh;
overflow: hidden; overflow: hidden;
@@ -1656,6 +1735,101 @@ h2 {
flex-wrap: wrap; flex-wrap: wrap;
} }
.studio-speed-knob {
--speed-knob-size: 58px;
--speed-knob-angle: 0deg;
position: relative;
display: inline-grid;
grid-template-columns: var(--speed-knob-size);
justify-items: center;
gap: 3px;
width: var(--speed-knob-size);
color: var(--text-muted);
font-family: var(--admin-font-mono);
font-size: 0.58rem;
line-height: 1;
text-transform: uppercase;
user-select: none;
}
.speed-knob-label,
.speed-knob-readout {
display: block;
overflow: hidden;
max-width: 100%;
color: var(--text);
white-space: nowrap;
text-overflow: ellipsis;
}
.speed-knob-face {
position: relative;
display: grid;
place-items: center;
width: 38px;
height: 38px;
border: 1px solid color-mix(in srgb, var(--primary) 54%, var(--border));
border-radius: 999px;
background:
radial-gradient(circle at 34% 28%, rgb(255 255 255 / 0.28), transparent 18%),
radial-gradient(circle at 50% 56%, color-mix(in srgb, var(--panel-soft) 64%, #000), color-mix(in srgb, var(--bg) 82%, #000));
box-shadow:
inset 0 0 0 2px rgb(0 0 0 / 0.28),
inset -6px -7px 12px rgb(0 0 0 / 0.36),
inset 5px 5px 10px rgb(255 255 255 / 0.08),
0 0 14px rgb(112 244 95 / 0.13);
}
.speed-knob-face::before {
content: "";
position: absolute;
inset: 5px;
border-radius: inherit;
border: 1px solid rgb(255 255 255 / 0.08);
}
.speed-knob-marker {
position: absolute;
top: 4px;
left: calc(50% - 2px);
width: 4px;
height: 12px;
border-radius: 99px;
background: var(--accent);
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 78%, transparent);
transform: rotate(var(--speed-knob-angle)) translateY(9px);
transform-origin: 50% 15px;
}
.studio-speed-knob input {
position: absolute;
inset: 13px 9px 12px;
display: block;
width: 40px;
height: 40px;
margin: 0;
opacity: 0.001;
cursor: pointer;
}
.studio-speed-knob:focus-within .speed-knob-face {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.viewer-speed-float {
position: absolute;
top: 14px;
right: 16px;
z-index: 4;
padding: 6px 10px;
border: 1px solid color-mix(in srgb, var(--border), transparent 12%);
border-radius: var(--radius);
background: color-mix(in srgb, var(--panel-soft), transparent 12%);
color: #dfffd7;
text-shadow: 0 0 8px rgb(112 244 95 / 0.55);
}
.toolbar-toggle { .toolbar-toggle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2302,6 +2476,48 @@ h2 {
width: 52px; width: 52px;
} }
:root[data-skin="windows-95"] .studio-speed-knob {
--speed-knob-size: 56px;
color: #000000;
font-family: var(--admin-font-mono);
text-shadow: none;
}
:root[data-skin="windows-95"] .topbar-speed-float,
:root[data-skin="windows-95"] .viewer-speed-float {
gap: 8px;
padding: 4px 8px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #c0c0c0;
color: #000000;
}
:root[data-skin="windows-95"] .topbar-speed-float .speed-knob-label,
:root[data-skin="windows-95"] .topbar-speed-float .speed-knob-readout,
:root[data-skin="windows-95"] .viewer-speed-float .speed-knob-label,
:root[data-skin="windows-95"] .viewer-speed-float .speed-knob-readout {
color: #000000;
}
:root[data-skin="windows-95"] .speed-knob-face {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background:
radial-gradient(circle at 35% 30%, #ffffff 0 9%, transparent 10%),
#c0c0c0;
box-shadow:
inset -4px -4px 0 #808080,
inset 3px 3px 0 #dfdfdf;
}
:root[data-skin="windows-95"] .speed-knob-marker {
width: 5px;
height: 13px;
background: #000080;
box-shadow: none;
}
:root[data-skin="windows-95"] .toolbar-toggle { :root[data-skin="windows-95"] .toolbar-toggle {
min-height: 34px; min-height: 34px;
padding: 5px 10px; padding: 5px 10px;
@@ -2524,6 +2740,29 @@ h2 {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.inspector-engine-settings {
display: grid;
gap: 10px;
}
.engine-setting-group {
display: grid;
gap: 8px;
min-width: 0;
margin: 0;
padding: 8px 0 0;
border: 0;
border-top: 1px solid color-mix(in srgb, var(--border), transparent 32%);
}
.engine-setting-group legend {
padding: 0 8px 0 0;
color: var(--accent);
font-family: var(--admin-font-mono);
font-size: 0.72rem;
text-transform: uppercase;
}
.engine-setting-toggle { .engine-setting-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2955,7 +3194,7 @@ h2 {
.viewer-rec-status { .viewer-rec-status {
position: absolute; position: absolute;
top: 14px; top: 14px;
right: 16px; left: 16px;
z-index: 4; z-index: 4;
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -2988,12 +3227,19 @@ h2 {
} }
.topbar, .topbar,
.editor-header, .editor-header {
.topbar-actions {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.topbar-actions {
align-items: center;
align-self: stretch;
flex-flow: row wrap;
justify-content: flex-start;
min-width: 0;
}
.status { .status {
text-align: left; text-align: left;
} }
+11 -4
View File
@@ -30,16 +30,23 @@ function voxelCells(settings: SceneParams): VoxelCell[] {
if (!Array.isArray(cells)) return [] if (!Array.isArray(cells)) return []
return cells.flatMap((cell) => { return cells.flatMap((cell) => {
const [x, y, z = 0, state = 1] = cell const rawCell = cell as unknown[]
if (!Number.isInteger(x) || !Number.isInteger(y) || !Number.isInteger(z)) return [] const [rawX, rawY, rawZ = 0] = rawCell
return [{ x, y, z, state: typeof state === 'number' ? state : 1 }] if (!Number.isInteger(rawX) || !Number.isInteger(rawY) || !Number.isInteger(rawZ)) return []
const x = rawX as number
const y = rawY as number
const z = rawZ as number
const rawState = rawCell.length >= 4 ? rawCell[3] : 'occupied'
if (rawState === null || rawState === 'none' || rawState === 'empty') return []
const state = typeof rawState === 'string' || typeof rawState === 'number' ? String(rawState) : 'occupied'
return [{ x, y, z, state }]
}) })
} }
export function Voxel3DRenderer({ caption, settings }: { caption: string; settings: SceneParams }) { export function Voxel3DRenderer({ caption, settings }: { caption: string; settings: SceneParams }) {
const mountRef = React.useRef<HTMLDivElement | null>(null) const mountRef = React.useRef<HTMLDivElement | null>(null)
const rendererRef = React.useRef<ReturnType<typeof createVoxelRendererCore> | null>(null) const rendererRef = React.useRef<ReturnType<typeof createVoxelRendererCore> | null>(null)
const cells = React.useMemo(() => voxelCells(settings), [settings]) const cells = voxelCells(settings)
const size = numberFromGrid(settings) const size = numberFromGrid(settings)
const ruleId = voxelRuleId(settings) const ruleId = voxelRuleId(settings)
const options = React.useMemo(() => rendererOptions(settings), [settings]) const options = React.useMemo(() => rendererOptions(settings), [settings])
+71 -9
View File
@@ -9,7 +9,7 @@ export interface VoxelCell {
x: number x: number
y: number y: number
z: number z: number
state?: number state?: string
} }
export interface VoxelRendererOptions { export interface VoxelRendererOptions {
@@ -51,8 +51,9 @@ function disposeMaterial(material: THREE.Material | THREE.Material[]) {
} }
} }
function disposeMesh(mesh: THREE.InstancedMesh | null) { function disposeMesh(scene: THREE.Scene, mesh: THREE.InstancedMesh | null) {
if (!mesh) return if (!mesh) return
scene.remove(mesh)
mesh.geometry.dispose() mesh.geometry.dispose()
disposeMaterial(mesh.material) disposeMaterial(mesh.material)
} }
@@ -111,14 +112,74 @@ function createVoxelMesh(size: number, options: VoxelRendererOptions) {
return mesh return mesh
} }
function colorForCell(bundle: SceneBundle, cell: VoxelCell, size: number, ruleId: string, target: THREE.Color) { const CATEGORICAL_STATE_COLORS = [
'#70f45f',
'#f0c94a',
'#35d6ff',
'#ff5fb7',
'#ff7a3d',
'#b28cff',
'#f7f7df',
'#42f5b0',
'#ffef5f',
'#6aa8ff'
]
function naturalStateSort(left: string, right: string) {
const leftNumber = Number(left)
const rightNumber = Number(right)
if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) return leftNumber - rightNumber
return left.localeCompare(right)
}
function stateKey(cell: VoxelCell) {
return cell.state ?? 'occupied'
}
function createStatePalette(cells: VoxelCell[], options: VoxelRendererOptions) {
const states = [...new Set(cells.map(stateKey))].sort(naturalStateSort)
const colors = new Map<string, THREE.Color>()
const indices = new Map<string, number>()
const primary = new THREE.Color(options.primaryColor)
const accent = new THREE.Color(options.accentColor)
states.forEach((state, index) => {
indices.set(state, index)
if (states.length === 1) {
colors.set(state, primary.clone())
return
}
const fixed = CATEGORICAL_STATE_COLORS[index % CATEGORICAL_STATE_COLORS.length]
const color = new THREE.Color(fixed)
if (index >= CATEGORICAL_STATE_COLORS.length) {
color.setHSL((index * 0.61803398875) % 1, 0.72, 0.56)
}
color.lerp(accent, index % 2 === 0 ? 0.08 : 0.16)
colors.set(state, color)
})
return { colors, indices }
}
function colorForCell(
bundle: SceneBundle,
cell: VoxelCell,
size: number,
ruleId: string,
statePalette: ReturnType<typeof createStatePalette>,
target: THREE.Color
) {
const heightMix = size > 1 ? cell.y / (size - 1) : 0.5 const heightMix = size > 1 ? cell.y / (size - 1) : 0.5
const depthMix = size > 1 ? cell.z / (size - 1) : 0.5 const depthMix = size > 1 ? cell.z / (size - 1) : 0.5
const stateMix = clamp((cell.state ?? 1) / 6, 0, 1) const paletteIndex = statePalette.indices.get(stateKey(cell)) ?? 0
const stateCount = Math.max(1, statePalette.colors.size - 1)
const stateMix = statePalette.colors.size > 1 ? clamp(paletteIndex / stateCount, 0, 1) : 0.5
target.copy(bundle.primaryColor).lerp(bundle.accentColor, clamp(heightMix * 0.42 + stateMix * 0.5, 0, 1)) target.copy(statePalette.colors.get(stateKey(cell)) ?? bundle.primaryColor)
target.lerp(bundle.accentColor, clamp(heightMix * 0.08, 0, 0.16))
if (ruleId === 'lattice-gas-3d' || ruleId === 'snake-3d') { if (ruleId === 'lattice-gas-3d' || ruleId === 'snake-3d' || ruleId === 'naga-3d') {
target.lerp(new THREE.Color('#ffffff'), clamp(stateMix * 0.22 + depthMix * 0.08, 0, 0.34)) target.lerp(new THREE.Color('#ffffff'), clamp(stateMix * 0.22 + depthMix * 0.08, 0, 0.34))
target.multiplyScalar(1 + stateMix * 0.16) target.multiplyScalar(1 + stateMix * 0.16)
return return
@@ -133,7 +194,7 @@ function colorForCell(bundle: SceneBundle, cell: VoxelCell, size: number, ruleId
} }
function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number, ruleId: string, options: VoxelRendererOptions) { function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number, ruleId: string, options: VoxelRendererOptions) {
disposeMesh(bundle.mesh) disposeMesh(bundle.scene, bundle.mesh)
bundle.mesh = createVoxelMesh(size, options) bundle.mesh = createVoxelMesh(size, options)
bundle.scene.add(bundle.mesh) bundle.scene.add(bundle.mesh)
@@ -142,6 +203,7 @@ function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number,
const color = new THREE.Color() const color = new THREE.Color()
const spacing = WORLD_SIZE / size const spacing = WORLD_SIZE / size
const origin = -WORLD_SIZE / 2 + spacing / 2 const origin = -WORLD_SIZE / 2 + spacing / 2
const statePalette = createStatePalette(cells, options)
let instanceIndex = 0 let instanceIndex = 0
for (const cell of cells) { for (const cell of cells) {
@@ -151,7 +213,7 @@ function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number,
dummy.position.set(origin + cell.x * spacing, origin + cell.y * spacing, origin + cell.z * spacing) dummy.position.set(origin + cell.x * spacing, origin + cell.y * spacing, origin + cell.z * spacing)
dummy.rotation.set(0, 0, 0) dummy.rotation.set(0, 0, 0)
dummy.updateMatrix() dummy.updateMatrix()
colorForCell(bundle, cell, size, ruleId, color) colorForCell(bundle, cell, size, ruleId, statePalette, color)
mesh.setMatrixAt(instanceIndex, dummy.matrix) mesh.setMatrixAt(instanceIndex, dummy.matrix)
mesh.setColorAt(instanceIndex, color) mesh.setColorAt(instanceIndex, color)
instanceIndex += 1 instanceIndex += 1
@@ -247,7 +309,7 @@ export function createVoxelRendererCore(targetNode: HTMLDivElement, options: Vox
dispose() { dispose() {
window.cancelAnimationFrame(bundle.animationFrame) window.cancelAnimationFrame(bundle.animationFrame)
bundle.resizeObserver.disconnect() bundle.resizeObserver.disconnect()
disposeMesh(bundle.mesh) disposeMesh(bundle.scene, bundle.mesh)
bundle.bounds.geometry.dispose() bundle.bounds.geometry.dispose()
disposeMaterial(bundle.bounds.material) disposeMaterial(bundle.bounds.material)
bundle.grid.geometry.dispose() bundle.grid.geometry.dispose()
+44
View File
@@ -0,0 +1,44 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
function parseEnvLine(line) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#'))
return null;
const equalsIndex = trimmed.indexOf('=');
if (equalsIndex === -1)
return null;
const key = trimmed.slice(0, equalsIndex).trim();
let value = trimmed.slice(equalsIndex + 1).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
return null;
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
return { key, value };
}
function loadEnvFile(path) {
if (!existsSync(path))
return;
const content = readFileSync(path, 'utf8');
for (const line of content.split(/\r?\n/)) {
const parsed = parseEnvLine(line);
if (!parsed || process.env[parsed.key] !== undefined)
continue;
process.env[parsed.key] = parsed.value;
}
}
export function loadEnv() {
const moduleDir = dirname(fileURLToPath(import.meta.url));
const candidatePaths = [
resolve(process.cwd(), '.env'),
resolve(process.cwd(), '..', '.env'),
resolve(moduleDir, '..', '.env'),
resolve(moduleDir, '..', '..', '.env')
];
for (const path of [...new Set(candidatePaths)]) {
loadEnvFile(path);
}
}
//# sourceMappingURL=loadEnv.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"loadEnv.js","sourceRoot":"","sources":["../src/loadEnv.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAClD,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAExC,SAAS,YAAY,CAAC,IAAY;IAChC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAA;IAC3B,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IAEpD,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;IACxC,IAAI,WAAW,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,CAAA;IAEnC,MAAM,GAAG,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC,IAAI,EAAE,CAAA;IAChD,IAAI,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,WAAW,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;IACjD,IAAI,CAAC,0BAA0B,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IAEtD,IACE,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QAC9C,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAC9C,CAAC;QACD,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC5B,CAAC;IAED,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,CAAA;AACvB,CAAC;AAED,SAAS,WAAW,CAAC,IAAY;IAC/B,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAM;IAE7B,MAAM,OAAO,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;IAC1C,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QAC1C,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,CAAA;QACjC,IAAI,CAAC,MAAM,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,SAAS;YAAE,SAAQ;QAC9D,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,KAAK,CAAA;IACxC,CAAC;AACH,CAAC;AAED,MAAM,UAAU,OAAO;IACrB,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;IACzD,MAAM,cAAc,GAAG;QACrB,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,MAAM,CAAC;QAC9B,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC;QACpC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,MAAM,CAAC;QAChC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC;KACvC,CAAA;IAED,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,cAAc,CAAC,CAAC,EAAE,CAAC;QAChD,WAAW,CAAC,IAAI,CAAC,CAAA;IACnB,CAAC;AACH,CAAC"}
+2
View File
@@ -1,5 +1,7 @@
import { Pool } from 'pg'; import { Pool } from 'pg';
import { createCaStudioApi } from './caStudioApi.js'; import { createCaStudioApi } from './caStudioApi.js';
import { loadEnv } from './loadEnv.js';
loadEnv();
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) { if (!databaseUrl) {
throw new Error('DATABASE_URL is required'); throw new Error('DATABASE_URL is required');
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AACzB,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAEpD,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAA;AAE5C,IAAI,CAAC,WAAW,EAAE,CAAC;IACjB,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAA;AAC7C,CAAC;AAED,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,EAAE,gBAAgB,EAAE,WAAW,EAAE,CAAC,CAAA;AACxD,MAAM,GAAG,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAA;AACnC,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAA;AAE7C,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,+CAA+C,IAAI,EAAE,CAAC,CAAA;AACpE,CAAC,CAAC,CAAA"} {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AACzB,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAEtC,OAAO,EAAE,CAAA;AAET,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAA;AAE5C,IAAI,CAAC,WAAW,EAAE,CAAC;IACjB,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAA;AAC7C,CAAC;AAED,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,EAAE,gBAAgB,EAAE,WAAW,EAAE,CAAC,CAAA;AACxD,MAAM,GAAG,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAA;AACnC,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAA;AAE7C,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,+CAA+C,IAAI,EAAE,CAAC,CAAA;AACpE,CAAC,CAAC,CAAA"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CA Studio Admin</title> <title>CA Studio Admin</title>
<script type="module" crossorigin src="/admin/assets/index-Bo_SPfbl.js"></script> <script type="module" crossorigin src="/admin/assets/index-nVGDjB10.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-C7KqEE0-.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-D7EhCe8j.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+49
View File
@@ -0,0 +1,49 @@
import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
function parseEnvLine(line) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) return null
const equalsIndex = trimmed.indexOf('=')
if (equalsIndex === -1) return null
const key = trimmed.slice(0, equalsIndex).trim()
let value = trimmed.slice(equalsIndex + 1).trim()
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
return { key, value }
}
function loadEnvFile(path) {
if (!existsSync(path)) return
const content = readFileSync(path, 'utf8')
for (const line of content.split(/\r?\n/)) {
const parsed = parseEnvLine(line)
if (!parsed || process.env[parsed.key] !== undefined) continue
process.env[parsed.key] = parsed.value
}
}
export function loadEnv() {
const moduleDir = dirname(fileURLToPath(import.meta.url))
const candidatePaths = [
resolve(process.cwd(), '.env'),
resolve(process.cwd(), '..', '.env'),
resolve(moduleDir, '..', '.env'),
resolve(moduleDir, '..', '..', '.env')
]
for (const path of [...new Set(candidatePaths)]) {
loadEnvFile(path)
}
}
+20
View File
@@ -2,6 +2,9 @@ import { readdir, readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { Pool } from 'pg' import { Pool } from 'pg'
import { loadEnv } from './load-env.mjs'
loadEnv()
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const migrationsDir = resolve(__dirname, '../src/migrations') const migrationsDir = resolve(__dirname, '../src/migrations')
@@ -10,6 +13,16 @@ const connectionString =
const pool = new Pool({ connectionString }) const pool = new Pool({ connectionString })
function redactConnectionString(value) {
try {
const url = new URL(value)
if (url.password) url.password = '***'
return url.toString()
} catch {
return value
}
}
try { try {
const migrationFiles = (await readdir(migrationsDir)) const migrationFiles = (await readdir(migrationsDir))
.filter((file) => file.endsWith('.sql')) .filter((file) => file.endsWith('.sql'))
@@ -21,6 +34,13 @@ try {
await pool.query(sql) await pool.query(sql)
console.log(`Applied migration ${migrationPath}`) console.log(`Applied migration ${migrationPath}`)
} }
} catch (error) {
if (error?.code === 'ECONNREFUSED') {
console.error(`Unable to connect to Postgres at ${redactConnectionString(connectionString)}`)
console.error('For the local Docker database, start it with `docker compose up -d postgres` from the repo root.')
console.error('Then use DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test')
}
throw error
} finally { } finally {
await pool.end() await pool.end()
} }
+49
View File
@@ -0,0 +1,49 @@
import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
function parseEnvLine(line: string) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) return null
const equalsIndex = trimmed.indexOf('=')
if (equalsIndex === -1) return null
const key = trimmed.slice(0, equalsIndex).trim()
let value = trimmed.slice(equalsIndex + 1).trim()
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
return { key, value }
}
function loadEnvFile(path: string) {
if (!existsSync(path)) return
const content = readFileSync(path, 'utf8')
for (const line of content.split(/\r?\n/)) {
const parsed = parseEnvLine(line)
if (!parsed || process.env[parsed.key] !== undefined) continue
process.env[parsed.key] = parsed.value
}
}
export function loadEnv() {
const moduleDir = dirname(fileURLToPath(import.meta.url))
const candidatePaths = [
resolve(process.cwd(), '.env'),
resolve(process.cwd(), '..', '.env'),
resolve(moduleDir, '..', '.env'),
resolve(moduleDir, '..', '..', '.env')
]
for (const path of [...new Set(candidatePaths)]) {
loadEnvFile(path)
}
}
@@ -0,0 +1,109 @@
WITH elementary_rules(slug, name, description, rule_id) AS (
VALUES
(
'elementary-rule-18',
'Elementary Rule 18',
'Sparse elementary 1D rule with nested, triangle-like growth from simple seeds.',
'Rule 18'
),
(
'elementary-rule-22',
'Elementary Rule 22',
'Elementary 1D rule known for irregular nested structures and chaotic-looking texture.',
'Rule 22'
),
(
'elementary-rule-45',
'Elementary Rule 45',
'Chaotic elementary 1D rule that produces asymmetric texture from small seeds.',
'Rule 45'
),
(
'elementary-rule-54',
'Elementary Rule 54',
'Particle-like elementary 1D rule with gliders and collision behavior.',
'Rule 54'
),
(
'elementary-rule-60',
'Elementary Rule 60',
'Additive elementary 1D rule that creates Sierpinski-style nested patterns.',
'Rule 60'
),
(
'elementary-rule-73',
'Elementary Rule 73',
'Elementary 1D rule with asymmetric localized structures and repeating motifs.',
'Rule 73'
),
(
'elementary-rule-90',
'Elementary Rule 90',
'Classic additive elementary 1D rule that generates the Sierpinski triangle.',
'Rule 90'
),
(
'elementary-rule-122',
'Elementary Rule 122',
'Elementary 1D rule with rich growth fronts and structured irregular regions.',
'Rule 122'
),
(
'elementary-rule-126',
'Elementary Rule 126',
'Expansive elementary 1D rule that quickly fills space with symmetric structure.',
'Rule 126'
),
(
'elementary-rule-150',
'Elementary Rule 150',
'Additive elementary 1D rule combining left, center, and right parity.',
'Rule 150'
),
(
'elementary-rule-184',
'Elementary Rule 184',
'Traffic-flow elementary 1D rule often used to model particles moving through a lane.',
'Rule 184'
)
)
INSERT INTO ca_engines (
slug,
name,
description,
engine_kind,
license,
owner,
ip_notice,
supported_classes,
params_schema,
default_params
)
SELECT
slug,
name,
description,
'elementary-1d',
'ruleset-reference',
'Glitch University',
'Registry metadata for elementary CA rules; custom engine implementations may carry separate licensing.',
'[{"dimensions":1,"states":2}]'::jsonb,
jsonb_build_object(
'ruleId',
jsonb_build_object(
'type', 'select',
'label', 'Rule',
'default', rule_id,
'options', jsonb_build_array(jsonb_build_object('value', rule_id, 'label', rule_id))
),
'rendererId',
jsonb_build_object(
'type', 'select',
'label', 'Renderer',
'default', 'elementary-1d',
'options', '[{"value":"elementary-1d","label":"Elementary 1D"}]'::jsonb
)
),
jsonb_build_object('ruleId', rule_id, 'rendererId', 'elementary-1d')
FROM elementary_rules
ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1,43 @@
INSERT INTO ca_engines (
slug,
name,
description,
engine_kind,
license,
owner,
ip_notice,
supported_classes,
params_schema,
default_params
)
VALUES (
'naga-3d',
'Naga 3D',
'Sparse 3D directional moykle rule that follows head-tail chains and moves tail symbols through voxel space.',
'naga-3d',
'internal',
'Glitch University',
'Experimental Naga engine implementation. Do not redistribute without the Glitch University CA engine licence.',
'[{"dimensions":3,"states":7}]'::jsonb,
'{
"ruleId": {
"type": "select",
"label": "Rule",
"default": "naga-3d",
"options": [{"value":"naga-3d","label":"3D Naga"}]
},
"rendererId": {
"type": "select",
"label": "Renderer",
"default": "voxel-3d",
"options": [{"value":"voxel-3d","label":"Voxel 3D"}]
},
"seed": {
"type": "string",
"label": "Seed",
"default": "naga"
}
}'::jsonb,
'{"ruleId":"naga-3d","rendererId":"voxel-3d","seed":"naga"}'::jsonb
)
ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1,49 @@
INSERT INTO ca_initial_condition_generators (
slug,
name,
description,
generator_kind,
supported_classes,
params_schema,
default_params
)
VALUES (
'naga-markov-chains',
'Naga Markov chains',
'Generates collision-free 3D Naga moykle chains. Each next moykle is placed at the previous position plus its directional state, with an adjustable probability of repeating the same state.',
'naga-markov',
'[{"dimensions":3,"states":7}]'::jsonb,
'{
"count": {
"type": "number",
"label": "Nagas",
"default": 3,
"min": 1,
"max": 64,
"step": 1
},
"length": {
"type": "number",
"label": "Length",
"default": 5,
"min": 1,
"max": 64,
"step": 1
},
"sameTypeProbability": {
"type": "range",
"label": "Same type probability",
"default": 0.8,
"min": 0,
"max": 1,
"step": 0.01
},
"seed": {
"type": "string",
"label": "Seed",
"default": "naga-markov"
}
}'::jsonb,
'{"count":3,"length":5,"sameTypeProbability":0.8,"seed":"naga-markov"}'::jsonb
)
ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1,13 @@
UPDATE ca_engines
SET
params_schema = params_schema || jsonb_build_object(
'edgeWrap',
jsonb_build_object(
'type', 'boolean',
'label', 'Edge wrap',
'default', true,
'group', 'Edge settings'
)
),
default_params = default_params || jsonb_build_object('edgeWrap', true)
WHERE slug = 'naga-3d';
+3
View File
@@ -1,5 +1,8 @@
import { Pool } from 'pg' import { Pool } from 'pg'
import { createCaStudioApi } from './caStudioApi.js' import { createCaStudioApi } from './caStudioApi.js'
import { loadEnv } from './loadEnv.js'
loadEnv()
const databaseUrl = process.env.DATABASE_URL const databaseUrl = process.env.DATABASE_URL
+71 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { listCaEngineRuntimes, registerCaEngineRuntime, stepCaCells } from '../admin/src/caEngines.js' import { listCaEngineRuntimes, registerCaEngineRuntime, stepCaCells, stepCaSimulation } from '../admin/src/caEngines.js'
import type { Cells, SceneParams } from '../admin/src/types.js' import type { Cells, SceneParams } from '../admin/src/types.js'
function cells(width: number, height: number, alive: Array<[number, number]> = []): Cells { function cells(width: number, height: number, alive: Array<[number, number]> = []): Cells {
@@ -28,6 +28,7 @@ describe('CA engine runtimes', () => {
'outer-totalistic-2d', 'outer-totalistic-2d',
'elementary-1d', 'elementary-1d',
'wildfire-2d', 'wildfire-2d',
'naga-3d',
'generic-voxel-ca', 'generic-voxel-ca',
'noop' 'noop'
]) ])
@@ -140,6 +141,75 @@ describe('CA engine runtimes', () => {
).toEqual(cells(3, 3)) ).toEqual(cells(3, 3))
}) })
it('steps Naga 3D sparse coordinates through the shared grid projection contract', () => {
const current = cells(5, 5)
const params = settings({
caClass: { dimensions: 3, states: 7 },
renderer: { id: 'voxel-3d' },
simulation: {
engineId: 'naga-3d',
ruleId: 'naga-3d',
grid: { size: [5, 5, 5], boundary: 'wrap' },
initialCondition: {
type: 'cells',
cells: [[2, 2, 2, 1]]
}
}
})
const stepped = stepCaSimulation(current, params)
expect(stepped.cells).toEqual(cells(5, 5, [[1, 2]]))
expect(params.simulation?.initialCondition?.cells).toEqual([[2, 2, 2, 1]])
expect(params.simulation?.nagaTick).toBeUndefined()
expect(stepped.settings.simulation?.initialCondition?.cells).toEqual([[1, 2, 2, 1]])
expect(stepped.settings.simulation?.nagaTick).toBe(1)
})
it('wraps Naga 3D sparse coordinates before computing voxel addresses', () => {
const current = cells(5, 5)
const params = settings({
caClass: { dimensions: 3, states: 7 },
renderer: { id: 'voxel-3d' },
simulation: {
engineId: 'naga-3d',
ruleId: 'naga-3d',
grid: { size: [5, 5, 5], boundary: 'wrap' },
initialCondition: {
type: 'cells',
cells: [[4, 2, 2, 4]]
}
}
})
const stepped = stepCaSimulation(current, params)
expect(stepped.settings.simulation?.initialCondition?.cells).toEqual([[0, 2, 2, 4]])
expect(stepped.cells).toEqual(cells(5, 5, [[0, 2]]))
})
it('keeps Naga 3D sparse coordinates in place when fixed boundaries would leave the grid', () => {
const current = cells(5, 5)
const params = settings({
caClass: { dimensions: 3, states: 7 },
renderer: { id: 'voxel-3d' },
simulation: {
engineId: 'naga-3d',
ruleId: 'naga-3d',
grid: { size: [5, 5, 5], boundary: 'fixed' },
initialCondition: {
type: 'cells',
cells: [[4, 2, 2, 4]]
}
}
})
const stepped = stepCaSimulation(current, params)
expect(stepped.settings.simulation?.initialCondition?.cells).toEqual([[4, 2, 2, 4]])
expect(stepped.cells).toEqual(cells(5, 5, [[4, 2]]))
})
it('defaults boundaries to wrap when the node does not specify a boundary condition', () => { it('defaults boundaries to wrap when the node does not specify a boundary condition', () => {
const current = cells(3, 3, [ const current = cells(3, 3, [
[2, 2], [2, 2],
@@ -8,8 +8,11 @@ import {
describe('CA initial condition runtimes', () => { describe('CA initial condition runtimes', () => {
it('registers the built-in random soup generator', () => { it('registers the built-in random soup generator', () => {
expect(listInitialConditionRuntimes().map((runtime) => runtime.id)).toContain('random-soup') expect(listInitialConditionRuntimes().map((runtime) => runtime.id)).toEqual(
expect.arrayContaining(['random-soup', 'naga-markov'])
)
expect(getInitialConditionRuntime('random-soup')?.label).toBe('Random soup') expect(getInitialConditionRuntime('random-soup')?.label).toBe('Random soup')
expect(getInitialConditionRuntime('naga-markov')?.label).toBe('Naga Markov chains')
}) })
it('generates deterministic cell coordinates for the same seed and density', () => { it('generates deterministic cell coordinates for the same seed and density', () => {
@@ -47,6 +50,49 @@ describe('CA initial condition runtimes', () => {
}).cells).toEqual([[1, 1, 0]]) }).cells).toEqual([[1, 1, 0]])
}) })
it('generates collision-free Naga Markov chains with directional continuity', () => {
const generated = generateInitialCondition('naga-markov', {
caClass: { dimensions: 3, states: 7 },
settings: { simulation: { grid: { size: [12, 12, 12] } } },
params: { count: 2, length: 5, sameTypeProbability: 1, seed: 'straight-nagas' }
})
expect(generated.cells).toHaveLength(10)
expect(new Set(generated.cells.map(([x, y, z]) => `${x}_${y}_${z}`)).size).toBe(10)
expect(generated.generator).toMatchObject({
kind: 'naga-markov',
count: 2,
length: 5,
sameTypeProbability: 1,
seed: 'straight-nagas',
created: 2
})
const occupied = new Set(generated.cells.map(([x, y, z]) => `${x}_${y}_${z}`))
for (let start = 0; start < generated.cells.length; start += 5) {
for (let index = start; index < start + 4; index += 1) {
const [x, y, z, arrow] = generated.cells[index]
const [nextX, nextY, nextZ, nextArrow] = generated.cells[index + 1]
expect(nextArrow).toBe(arrow)
if (arrow === 1) expect([nextX, nextY, nextZ]).toEqual([x + 1, y, z])
if (arrow === 2) expect([nextX, nextY, nextZ]).toEqual([x, y + 1, z])
if (arrow === 3) expect([nextX, nextY, nextZ]).toEqual([x, y, z + 1])
if (arrow === 4) expect([nextX, nextY, nextZ]).toEqual([x - 1, y, z])
if (arrow === 5) expect([nextX, nextY, nextZ]).toEqual([x, y - 1, z])
if (arrow === 6) expect([nextX, nextY, nextZ]).toEqual([x, y, z - 1])
}
const [x, y, z, arrow] = generated.cells[start + 4]
const terminalTarget =
arrow === 1 ? [x + 1, y, z] :
arrow === 2 ? [x, y + 1, z] :
arrow === 3 ? [x, y, z + 1] :
arrow === 4 ? [x - 1, y, z] :
arrow === 5 ? [x, y - 1, z] :
[x, y, z - 1]
expect(occupied.has(terminalTarget.join('_'))).toBe(false)
}
})
it('rejects duplicate runtime ids and missing implementations', () => { it('rejects duplicate runtime ids and missing implementations', () => {
expect(() => expect(() =>
registerInitialConditionRuntime({ registerInitialConditionRuntime({
+92
View File
@@ -4,6 +4,98 @@ import { createCaStudioApi } from '../src/caStudioApi.js'
import { createTestDb } from './testDb.js' import { createTestDb } from './testDb.js'
describe('CA Studio API', () => { describe('CA Studio API', () => {
it('seeds a useful shelf of elementary 1D CA rules', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
await request(app)
.get('/api/ca/engines?dimensions=1&states=2')
.expect(200)
.expect((response) => {
const rules = response.body
.filter((engine: { engine_kind: string }) => engine.engine_kind === 'elementary-1d')
.map((engine: { default_params: { ruleId?: string } }) => engine.default_params.ruleId)
expect(rules).toEqual(expect.arrayContaining([
'Rule 18',
'Rule 22',
'Rule 30',
'Rule 45',
'Rule 54',
'Rule 60',
'Rule 73',
'Rule 90',
'Rule 110',
'Rule 122',
'Rule 126',
'Rule 150',
'Rule 184'
]))
const rule90 = response.body.find((engine: { slug: string }) => engine.slug === 'elementary-rule-90')
expect(rule90.params_schema.ruleId).toMatchObject({
type: 'select',
default: 'Rule 90',
options: [{ value: 'Rule 90', label: 'Rule 90' }]
})
})
})
it('seeds the Naga 3D engine for seven-state voxel spaces', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
await request(app)
.get('/api/ca/engines?dimensions=3&states=7')
.expect(200)
.expect((response) => {
const naga = response.body.find((engine: { slug: string }) => engine.slug === 'naga-3d')
expect(naga).toMatchObject({
name: 'Naga 3D',
engine_kind: 'naga-3d',
default_params: {
ruleId: 'naga-3d',
rendererId: 'voxel-3d',
seed: 'naga',
edgeWrap: true
}
})
expect(naga.params_schema.ruleId.options).toEqual([{ value: 'naga-3d', label: '3D Naga' }])
expect(naga.params_schema.edgeWrap).toMatchObject({
type: 'boolean',
label: 'Edge wrap',
default: true,
group: 'Edge settings'
})
})
})
it('seeds the Naga Markov initial condition generator for seven-state voxel spaces', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
await request(app)
.get('/api/ca/initial-condition-generators?dimensions=3&states=7')
.expect(200)
.expect((response) => {
const generator = response.body.find((item: { slug: string }) => item.slug === 'naga-markov-chains')
expect(generator).toMatchObject({
name: 'Naga Markov chains',
generator_kind: 'naga-markov',
default_params: {
count: 3,
length: 5,
sameTypeProbability: 0.8,
seed: 'naga-markov'
}
})
expect(generator.params_schema.sameTypeProbability).toMatchObject({
type: 'range',
default: 0.8
})
})
})
it('lists preset nodes whose resolved settings use an engine', async () => { it('lists preset nodes whose resolved settings use an engine', async () => {
const db = await createTestDb() const db = await createTestDb()
const app = createCaStudioApi(db) const app = createCaStudioApi(db)
+3 -4
View File
@@ -94,10 +94,9 @@ describe('CA Studio repository', () => {
supportedClasses: [{ neighborhoodId: 'von-neumann', dimensions: 2, states: 3 }] supportedClasses: [{ neighborhoodId: 'von-neumann', dimensions: 2, states: 3 }]
}) })
expect((await listInitialConditionGenerators(db)).map((generator) => generator.slug)).toEqual([ expect((await listInitialConditionGenerators(db)).map((generator) => generator.slug)).toEqual(
'life-random-soup', expect.arrayContaining(['life-random-soup', 'von-neumann-traffic', 'naga-markov-chains'])
'von-neumann-traffic' )
])
expect( expect(
(await listInitialConditionGenerators(db, { (await listInitialConditionGenerators(db, {
caClass: { neighborhoodId: 'moore', dimensions: 2, states: 2 } caClass: { neighborhoodId: 'moore', dimensions: 2, states: 2 }