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
+4 -1
View File
@@ -531,7 +531,10 @@ registerCaRendererRuntime(
id: 'voxel-3d',
label: 'Voxel 3D',
aliases: ['three-voxel'],
supportedClasses: [{ dimensions: 3, states: 2 }],
supportedClasses: [
{ dimensions: 3, states: 2 },
{ dimensions: 3, states: 7 }
],
Component: Voxel3DRendererAdapter
},
{ replace: true }
+13 -3
View File
@@ -570,9 +570,19 @@ function NodePropertiesModal({
<p className="eyebrow">Preset Editor</p>
<h2>Edit preset</h2>
</div>
<button type="button" onClick={onClose}>
Close
</button>
<span className="win95-window-controls has-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>
</span>
</header>
<div className="property-modal-list">
<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 {
assertSameCellsShape,
cloneCells,
resolveBoundaryCondition,
resolveBoundaryIndex,
seededRandom
} from './caRuntime.js'
import { assertSameCellsShape } from './caRuntime.js'
import { stepElementary1d } from './caEngineSteps/elementary1d.js'
import { stepNaga3d } from './caEngineSteps/naga.js'
import { stepNoop } from './caEngineSteps/noop.js'
import { stepOuterTotalistic2d } from './caEngineSteps/outerTotalistic2d.js'
import { stepWildfire2d } from './caEngineSteps/wildfire2d.js'
import type { CaClass, Cells, JsonObject, SceneParams } from './types.js'
export interface CaEngineRuntime {
@@ -15,132 +14,13 @@ export interface CaEngineRuntime {
step: (cells: Cells, settings: SceneParams) => Cells
}
export interface CaStepResult {
cells: Cells
settings: SceneParams
}
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 } = {}) {
if (!engine.id.trim()) throw new Error('CA engine runtime id is required')
if (engineRuntimes.has(engine.id) && !options.replace) {
@@ -154,11 +34,16 @@ export function listCaEngineRuntimes() {
}
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 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'})`)
return next
return { cells: next, settings: nextSettings }
}
registerCaEngineRuntime(
@@ -205,6 +90,17 @@ registerCaEngineRuntime(
{ 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(
{
id: 'generic-voxel-ca',
@@ -1,4 +1,5 @@
import { numericGridSize, seededRandom } from './caRuntime.js'
import { fVec, type NagaArrow } from './caEngineSteps/naga.js'
import type { CaClass, JsonObject, SceneParams } from './types.js'
export interface InitialConditionResult {
@@ -27,6 +28,14 @@ function clampDensity(value: unknown) {
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 {
const density = clampDensity(params.density)
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(
runtime: InitialConditionRuntime,
options: { replace?: boolean } = {}
@@ -91,3 +210,12 @@ registerInitialConditionRuntime(
},
{ replace: true }
)
registerInitialConditionRuntime(
{
id: 'naga-markov',
label: 'Naga Markov chains',
generate: nagaMarkov
},
{ replace: true }
)
+425 -106
View File
@@ -1,6 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { stepCaCells } from './caEngines.js'
import { stepCaCells, stepCaSimulation } from './caEngines.js'
import { generateInitialCondition } from './caInitialConditionGenerators.js'
import { getBoundaryCondition, getCaClassFromParams, supportsCaClass as supportsRootClass } from './caRuntime.js'
import { CaRenderer, listCompatibleCaRendererRuntimes } from './CaRenderer.js'
@@ -50,6 +50,9 @@ type EngineDimension = 1 | 2 | 3
type IcgDimension = 1 | 2 | 3
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 {
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)
}
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) {
if (!(target instanceof HTMLElement)) return false
const tagName = target.tagName.toLowerCase()
@@ -324,6 +371,7 @@ function ruleLabel(ruleId: string) {
if (ruleId === 'generations-3d') return '3D Generations'
if (ruleId === 'lattice-gas-3d') return '3D Lattice gas'
if (ruleId === 'snake-3d') return '3D Snake'
if (ruleId === 'naga-3d') return '3D Naga'
return ruleId
}
@@ -428,11 +476,19 @@ function getParamPath(value: JsonObject, path: string[]) {
function engineParamPath(key: string) {
if (key === 'rendererId') return ['renderer', 'id']
if (key === 'ruleId') return ['simulation', 'ruleId']
if (key === 'edgeWrap') return ['simulation', 'grid', 'wrap']
if (key.startsWith('grid.')) return ['simulation', ...key.split('.')]
if (key.includes('.')) return key.split('.')
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) {
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 {
if (!shouldProjectCellsIntoSettings(getCaClassFromParams(settings))) return settings
return mergeParamObjects(settings, {
simulation: {
initialCondition: {
@@ -645,16 +703,87 @@ function formatTaskbarClock(date: Date) {
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
function Win95WindowControls() {
function WindowControls({
closeLabel = 'Close',
disabled = false,
onClose
}: {
closeLabel?: string
disabled?: boolean
onClose?: () => void
}) {
return (
<span className="win95-window-controls" aria-hidden="true">
<span>_</span>
<span></span>
<span>×</span>
<span className={`win95-window-controls${onClose ? ' has-close' : ''}`} aria-hidden={onClose ? undefined : 'true'}>
<span className="window-control-decor">_</span>
<span className="window-control-decor"></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>
)
}
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 }) {
const [activeTab, setActiveTab] = React.useState<GalleryTab>(initialTab)
const [engineDimension, setEngineDimension] = React.useState<EngineDimension>(() => {
@@ -984,7 +1113,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
</p>
</div>
<div className="status">{status}</div>
<Win95WindowControls />
<WindowControls />
</header>
<nav aria-label="Gallery sections" className="gallery-tabs">
@@ -1387,8 +1516,7 @@ function GalleryCreationModal({
<h2>New {labels[activeTab]}</h2>
</div>
<div className="window-action-cluster">
<Win95WindowControls />
<button type="button" onClick={onClose}>Close</button>
<WindowControls onClose={onClose} />
</div>
</header>
<div className="gallery-create-body">
@@ -1621,8 +1749,7 @@ function EngineEditModal({
<h2>Edit {engine.name}</h2>
</div>
<div className="window-action-cluster">
<Win95WindowControls />
<button disabled={saving} type="button" onClick={onClose}>Close</button>
<WindowControls disabled={saving} onClose={onClose} />
</div>
</header>
<div className="gallery-create-body">
@@ -1687,24 +1814,42 @@ function InspectorEngineSettings({
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>
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 (
<div className="engine-settings-grid inspector-engine-settings">
{entries.map(([key, schema]) => {
const path = engineParamPath(key)
const value = getParamPath(settings, path) ?? schemaDefault(key, schema, engine)
return (
<InspectorEngineSettingControl
disabled={disabled}
engine={engine}
key={key}
paramKey={key}
path={path}
schema={schema}
value={value}
onSet={onSet}
/>
)
})}
<div className="inspector-engine-settings">
{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 value = engineSettingValue(settings, engine, key, schema)
return (
<InspectorEngineSettingControl
disabled={disabled}
engine={engine}
key={key}
paramKey={key}
path={path}
schema={schema}
value={value}
onSet={onSet}
/>
)
})}
</div>
</fieldset>
))}
</div>
)
}
@@ -1868,8 +2013,7 @@ function IcgEditModal({
<h2>Edit {generator.name}</h2>
</div>
<div className="window-action-cluster">
<Win95WindowControls />
<button disabled={saving} type="button" onClick={onClose}>Close</button>
<WindowControls disabled={saving} onClose={onClose} />
</div>
</header>
<div className="gallery-create-body">
@@ -1982,6 +2126,9 @@ function LibraryEditor({
const [selectedIcgId, setSelectedIcgId] = React.useState('')
const [selectedEngineId, setSelectedEngineId] = React.useState('')
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 [nodeName, setNodeName] = React.useState('Untitled preset')
const [nodeDescription, setNodeDescription] = React.useState('')
@@ -1993,8 +2140,10 @@ function LibraryEditor({
const [engineId, setEngineId] = React.useState('')
const [boundaryCondition, setBoundaryCondition] = React.useState<BoundaryCondition>('wrap')
const [cells, setCells] = React.useState<Cells>(() => emptyCells())
const [runtimeSettings, setRuntimeSettings] = React.useState<SceneParams | null>(null)
const [generation, setGeneration] = React.useState(0)
const [running, setRunning] = React.useState(false)
const [speedLevel, setSpeedLevel] = React.useState(DEFAULT_SPEED_LEVEL)
const [initialConditionDirty, setInitialConditionDirty] = React.useState(false)
const [initialConditionSaving, setInitialConditionSaving] = React.useState(false)
const [treeDrawerOpen, setTreeDrawerOpen] = React.useState(
@@ -2019,19 +2168,40 @@ function LibraryEditor({
? (mergeParamObjects(
mergeParamObjects(resolveParentParams(nodes, selectedNode), selectedNode.params),
mergeParamObjects(
createShotParams(ruleId, neighborhoodId, rendererId, cells, engineId, boundaryCondition),
createShotParams(
ruleId,
neighborhoodId,
rendererId,
cells,
engineId,
boundaryCondition,
shouldProjectCellsIntoSettings(activeCaClass)
),
{ caClass: activeCaClass }
)
) as SceneParams)
: (mergeParamObjects(
createShotParams(ruleId, neighborhoodId, rendererId, cells, engineId, boundaryCondition),
createShotParams(
ruleId,
neighborhoodId,
rendererId,
cells,
engineId,
boundaryCondition,
shouldProjectCellsIntoSettings(activeCaClass)
),
{ caClass: activeCaClass }
) as SceneParams)
const previewSettings = runtimeSettings ?? resolvedNodeSettings
const selectedNodeClass = getCaClassFromParams(resolvedNodeSettings)
const compatibleIcgs = React.useMemo(
() => icgs.filter((generator) => supportsRootClass(generator, selectedNodeClass)),
[icgs, selectedNodeClass]
)
const selectedIcg = React.useMemo(
() => compatibleIcgs.find((generator) => generator.id === selectedIcgId) ?? null,
[compatibleIcgs, selectedIcgId]
)
const compatibleEngines = React.useMemo(
() => engines.filter((engine) => supportsRootClass(engine, selectedNodeClass)),
[engines, selectedNodeClass]
@@ -2125,11 +2295,15 @@ function LibraryEditor({
React.useEffect(() => {
if (!running) return
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)
}, 220)
}, playbackIntervalMs(speedLevel))
return () => window.clearInterval(timer)
}, [ruleId, running])
}, [previewSettings, running, speedLevel])
React.useEffect(() => {
if (!selectedNode || editorRevision === 0 || hydratedRef.current) return
@@ -2204,6 +2378,7 @@ function LibraryEditor({
api<ResolvedNode>(`/api/ca/preset-trees/${treeId}/nodes/${nodeId}/resolved`)
])
setSelectedNodeId(node.id)
setRuntimeSettings(null)
setInitialConditionDirty(false)
setNodeName(node.name)
setNodeDescription(node.description ?? '')
@@ -2446,57 +2621,83 @@ function LibraryEditor({
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
const generator = compatibleIcgs.find((item) => item.id === selectedIcgId)
if (!generator) return
try {
setRunning(false)
const density = Math.max(0, Math.min(1, icgDensity))
const initialCondition = generateInitialCondition(generator.generator_kind, {
settings: resolvedNodeSettings,
caClass: selectedNodeClass,
params: { density, seed: icgSeed }
params: selectedIcgParams(generator)
})
const inheritedParams = resolveParentParams(nodes, selectedNode)
const params = pruneInheritedParams(
mergeParamObjects(selectedNode.params, { simulation: { initialCondition } }),
inheritedParams
) as SceneParams
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)))
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
)
setCells(coordinatesToCells(initialCondition.cells))
setInitialConditionDirty(false)
setInitialConditionDirty(true)
setGeneration(0)
setStatus(`Generated initial condition on "${selectedNode.name}"`)
setStatus(`Generated unsaved initial condition on "${selectedNode.name}"`)
} catch (error) {
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)
setSelectedEngineId(nextEngineId)
if (!engine) {
setEngineId('')
setRuntimeSettings(null)
if (selectedNode) {
setNodes((current) =>
current.map((node) =>
node.id === selectedNode.id
? { ...node, params: setParamPath(node.params, ['simulation', 'engineId'], null) }
: node
)
)
markEdited()
try {
const params = setParamPath(selectedNode.params, ['simulation', 'engineId'], null)
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(`Unset evolution function on "${selectedNode.name}"`)
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Engine update failed')
}
}
return
}
setEngineId(engine.engine_kind)
setRuntimeSettings(null)
if (typeof engine.default_params.ruleId === 'string') setRuleId(engine.default_params.ruleId)
if (
typeof engine.default_params.rendererId === 'string' &&
@@ -2505,15 +2706,25 @@ function LibraryEditor({
setRendererId(engine.default_params.rendererId)
}
if (selectedNode) {
void setNodeParam(selectedNode, ['simulation', 'engineId'], engine.engine_kind)
for (const [key, value] of Object.entries(engine.default_params)) {
void setNodeParam(selectedNode, engineParamPath(key), value)
try {
let params = setParamPath(selectedNode.params, ['simulation', 'engineId'], engine.engine_kind)
for (const [key, value] of Object.entries(engine.default_params)) {
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
const joinedPath = path.join('.')
if (joinedPath === 'simulation.ruleId' && typeof value === 'string') setRuleId(value)
@@ -2523,7 +2734,22 @@ function LibraryEditor({
}
if (joinedPath === 'simulation.grid.wrap' && typeof value === 'boolean') {
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)
}
@@ -2567,14 +2793,15 @@ function LibraryEditor({
setDimensions(nextDimensions)
setStateCount(nextStateCount)
setNeighborhoodId(nextNeighborhoodId)
setRuntimeSettings(null)
setCompatibilityWarning(warnings.join('. '))
setNodes((current) =>
current.map((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)
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 }
})
)
@@ -2584,10 +2811,40 @@ function LibraryEditor({
function updateCells(next: Cells) {
setRunning(false)
setGeneration(0)
setRuntimeSettings(null)
setCells(next)
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() {
if (!selectedNode || initialConditionSaving) return
@@ -2595,12 +2852,15 @@ function LibraryEditor({
setInitialConditionSaving(true)
setRunning(false)
const inheritedParams = resolveParentParams(nodes, selectedNode)
const sparseCells = isSparseVoxelClass(activeCaClass)
? previewSettings.simulation?.initialCondition?.cells ?? resolvedNodeSettings.simulation?.initialCondition?.cells ?? []
: cellsToCoordinates(cells)
const params = pruneInheritedParams(
mergeParamObjects(selectedNode.params, {
simulation: {
initialCondition: {
type: 'cells',
cells: cellsToCoordinates(cells)
cells: sparseCells
}
}
}),
@@ -2611,6 +2871,7 @@ function LibraryEditor({
body: JSON.stringify({ params })
})
setNodes((current) => current.map((node) => (node.id === updated.id ? updated : node)))
setRuntimeSettings(null)
setInitialConditionDirty(false)
setGeneration(0)
setStatus(`Saved initial condition on "${selectedNode.name}"`)
@@ -2623,12 +2884,16 @@ function LibraryEditor({
function previewStep() {
setRunning(false)
setCells((current) => stepCaCells(current, resolvedNodeSettings))
setCells((current) => {
const stepped = stepCaSimulation(current, previewSettings)
setRuntimeSettings(stepped.settings)
return stepped.cells
})
setGeneration((current) => current + 1)
}
return (
<main className="studio-shell preset-studio-shell">
<main className={`studio-shell preset-studio-shell${inspectorOpen ? ' inspector-open' : ''}`}>
<header className="topbar preset-studio-topbar">
<div className="preset-library-heading">
<p className="eyebrow">Preset Library</p>
@@ -2637,9 +2902,12 @@ function LibraryEditor({
Back
</button>
</div>
<div className="topbar-speed-float" aria-label="Simulation speed">
<StudioSpeedKnob speedLevel={speedLevel} onChange={setSpeedLevel} />
</div>
<div className="topbar-actions">
<div className="status">{status}</div>
<Win95WindowControls />
<WindowControls />
</div>
</header>
@@ -2658,10 +2926,7 @@ function LibraryEditor({
<p className="eyebrow">Presets</p>
<h2>Library tree</h2>
</div>
<button aria-label="Close preset drawer" className="icon-button drawer-close" type="button" onClick={() => setTreeDrawerOpen(false)}>
×
</button>
<Win95WindowControls />
<WindowControls closeLabel="Close preset drawer" onClose={() => setTreeDrawerOpen(false)} />
</div>
<div className="drawer-scroll">
<div className="root-selector">
@@ -2727,7 +2992,7 @@ function LibraryEditor({
</button>
</div>
) : null}
<CaRenderer caption="" cells={cells} settings={resolvedNodeSettings} onCellsChange={updateCells} />
<CaRenderer caption="" cells={cells} settings={previewSettings} onCellsChange={updateCells} />
</div>
<footer className="metrics preset-stage-metrics">
@@ -2749,10 +3014,7 @@ function LibraryEditor({
<p className="eyebrow">Inspector</p>
<h2>{selectedNode ? nodeName : 'No preset selected'}</h2>
</div>
<button aria-label="Close inspector" className="icon-button drawer-close" type="button" onClick={() => setInspectorOpen(false)}>
×
</button>
<Win95WindowControls />
<WindowControls closeLabel="Close inspector" onClose={() => setInspectorOpen(false)} />
</div>
<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>
@@ -2872,7 +3134,7 @@ function LibraryEditor({
{inspectorTab === 'initial' ? (
<div className="inspector-section">
<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
className="primary"
disabled={!selectedNode || !initialConditionDirty || initialConditionSaving}
@@ -2894,25 +3156,49 @@ function LibraryEditor({
<select value={selectedIcgId} onChange={(event) => {
const generator = compatibleIcgs.find((item) => item.id === event.target.value)
setSelectedIcgId(event.target.value)
if (typeof generator?.default_params.density === 'number') setIcgDensity(generator.default_params.density)
if (typeof generator?.default_params.seed === 'string') setIcgSeed(generator.default_params.seed)
applyIcgDefaults(generator)
}}>
<option value="">Select generator</option>
{compatibleIcgs.map((generator) => <option key={generator.id} value={generator.id}>{generator.name}</option>)}
</select>
</label>
<div className="icg-controls">
<label>
Density
<input max={1} min={0} step={0.01} type="range" value={icgDensity} onChange={(event) => setIcgDensity(Number(event.target.value))} />
</label>
<strong>{Math.round(icgDensity * 100)}%</strong>
</div>
{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">
<label>
Density
<input max={1} min={0} step={0.01} type="range" value={icgDensity} onChange={(event) => setIcgDensity(Number(event.target.value))} />
</label>
<strong>{Math.round(icgDensity * 100)}%</strong>
</div>
)}
<label>
Seed
<input value={icgSeed} onChange={(event) => setIcgSeed(event.target.value)} />
</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
</button>
</>
@@ -2979,9 +3265,7 @@ function SlideMetadataModal({
<p className="eyebrow">Slide Metadata</p>
<h2>{scene.scene.title}</h2>
</div>
<button type="button" onClick={onClose}>
Close
</button>
<WindowControls onClose={onClose} />
</header>
<div className="slide-metadata-body">
<label>
@@ -3075,7 +3359,7 @@ function CaAssetPickerModal({
<p className="eyebrow">CA Assets</p>
<h2>Select a preset</h2>
</div>
<button type="button" onClick={onClose}>Close</button>
<WindowControls onClose={onClose} />
</header>
<div className="ca-asset-layout">
<aside className="ca-asset-browser">
@@ -3155,6 +3439,7 @@ function DeckViewer({ deckId }: { deckId: string }) {
const [cells, setCells] = React.useState<Cells>(() => emptyCells())
const [generation, setGeneration] = React.useState(0)
const [running, setRunning] = React.useState(false)
const [speedLevel, setSpeedLevel] = React.useState(DEFAULT_SPEED_LEVEL)
const [status, setStatus] = React.useState('Loading viewer...')
const settingsRef = React.useRef(settings)
const sceneIndexRef = React.useRef(sceneIndex)
@@ -3197,7 +3482,12 @@ function DeckViewer({ deckId }: { deckId: string }) {
)
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)
}, [])
@@ -3228,9 +3518,9 @@ function DeckViewer({ deckId }: { deckId: string }) {
React.useEffect(() => {
if (!running) return
const timer = window.setInterval(stepOnce, 220)
const timer = window.setInterval(stepOnce, playbackIntervalMs(speedLevel))
return () => window.clearInterval(timer)
}, [running, stepOnce])
}, [running, speedLevel, stepOnce])
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
@@ -3275,6 +3565,9 @@ function DeckViewer({ deckId }: { deckId: string }) {
<main className="viewer-shell">
<section className="viewer-stage" aria-label={activeScene?.scene.title ?? status}>
<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">
<span>{resolvedDeck?.deck.title ?? 'CA Deck'}</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 [boundaryCondition, setBoundaryCondition] = React.useState<BoundaryCondition>('wrap')
const [cells, setCells] = React.useState<Cells>(() => placeGliderCells())
const [runtimeSettings, setRuntimeSettings] = React.useState<SceneParams | null>(null)
const [generation, setGeneration] = React.useState(0)
const [running, setRunning] = React.useState(false)
const [speedLevel, setSpeedLevel] = React.useState(DEFAULT_SPEED_LEVEL)
const [status, setStatus] = React.useState('Loading deck...')
const hydratedRef = 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 assignedNodeId = activeScene?.scene.preset_node_id
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
? (mergeParamObjects(
mergeParamObjects(resolveParentParams(nodes, selectedNode), selectedNode.params),
createShotParams(ruleId, neighborhoodId, rendererId, cells, undefined, boundaryCondition)
selectedNodeBaseSettings ?? {},
createShotParams(
ruleId,
neighborhoodId,
rendererId,
cells,
undefined,
boundaryCondition,
shouldProjectCellsIntoSettings(selectedNodeBaseClass)
)
) as SceneParams)
: createShotParams(ruleId, neighborhoodId, rendererId, cells, undefined, boundaryCondition)
const previewSettings = runtimeSettings ?? resolvedNodeSettings
const refreshDeck = React.useCallback(async () => {
const loadedDeck = await api<Deck>(`/api/ca/decks/${deckId}`)
@@ -3419,11 +3727,15 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
React.useEffect(() => {
if (!running) return
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)
}, 220)
}, playbackIntervalMs(speedLevel))
return () => window.clearInterval(timer)
}, [ruleId, running])
}, [previewSettings, running, speedLevel])
React.useEffect(() => {
if (!deck || deckTitleHydratedRef.current || deckTitle === deck.title) return
@@ -3504,6 +3816,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
])
setNodes(treeNodes)
setSelectedNodeId(nodeId)
setRuntimeSettings(null)
setSceneName(node.name || fallbackName || 'Untitled node')
setRendererId(resolved.params.renderer?.id ?? '2d-canvas')
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 : '')
setAutoplayOnSlideChange(shouldAutoplay)
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 })
return
}
setSelectedNodeId('')
setRuntimeSettings(null)
setSceneName('No CA preset')
setCells(emptyCells())
setGeneration(0)
@@ -3708,7 +4023,11 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
function previewStep() {
setRunning(false)
setCells((current) => stepCaCells(current, resolvedNodeSettings))
setCells((current) => {
const stepped = stepCaSimulation(current, previewSettings)
setRuntimeSettings(stepped.settings)
return stepped.cells
})
setGeneration((current) => current + 1)
}
@@ -3785,7 +4104,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
</div>
<div className="topbar-actions">
<div className="status">{status}</div>
<Win95WindowControls />
<WindowControls />
</div>
</header>
@@ -3841,7 +4160,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
<h2>{activeScene ? slideTitle : sceneName}</h2>
</div>
<div className="button-row">
<Win95WindowControls />
<WindowControls />
<button
aria-label="Previous slide"
className="deck-nav-button"
@@ -3886,7 +4205,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
<CaRenderer
caption={activeScene ? caption : ''}
cells={cells}
settings={resolvedNodeSettings}
settings={previewSettings}
onCellsChange={() => undefined}
/>
) : (
+250 -4
View File
@@ -451,6 +451,35 @@ label {
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 {
display: flex;
align-items: center;
@@ -491,6 +520,43 @@ label {
.topbar-actions {
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 {
@@ -617,12 +683,17 @@ h2 {
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;
place-items: center;
width: 18px;
min-width: 18px;
height: 17px;
min-height: 17px;
padding: 0;
border: 2px solid;
border-radius: 0;
border-color: #ffffff #404040 #404040 #ffffff;
background: #c0c0c0;
color: #000000;
@@ -1157,6 +1228,7 @@ h2 {
}
.preset-studio-shell {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: calc(100dvh - 73px);
@@ -1165,6 +1237,13 @@ h2 {
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) {
height: 100dvh;
overflow: hidden;
@@ -1656,6 +1735,101 @@ h2 {
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 {
display: inline-flex;
align-items: center;
@@ -2302,6 +2476,48 @@ h2 {
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 {
min-height: 34px;
padding: 5px 10px;
@@ -2524,6 +2740,29 @@ h2 {
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 {
display: flex;
align-items: center;
@@ -2955,7 +3194,7 @@ h2 {
.viewer-rec-status {
position: absolute;
top: 14px;
right: 16px;
left: 16px;
z-index: 4;
display: flex;
gap: 10px;
@@ -2988,12 +3227,19 @@ h2 {
}
.topbar,
.editor-header,
.topbar-actions {
.editor-header {
align-items: flex-start;
flex-direction: column;
}
.topbar-actions {
align-items: center;
align-self: stretch;
flex-flow: row wrap;
justify-content: flex-start;
min-width: 0;
}
.status {
text-align: left;
}
+11 -4
View File
@@ -30,16 +30,23 @@ function voxelCells(settings: SceneParams): VoxelCell[] {
if (!Array.isArray(cells)) return []
return cells.flatMap((cell) => {
const [x, y, z = 0, state = 1] = cell
if (!Number.isInteger(x) || !Number.isInteger(y) || !Number.isInteger(z)) return []
return [{ x, y, z, state: typeof state === 'number' ? state : 1 }]
const rawCell = cell as unknown[]
const [rawX, rawY, rawZ = 0] = rawCell
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 }) {
const mountRef = React.useRef<HTMLDivElement | 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 ruleId = voxelRuleId(settings)
const options = React.useMemo(() => rendererOptions(settings), [settings])
+71 -9
View File
@@ -9,7 +9,7 @@ export interface VoxelCell {
x: number
y: number
z: number
state?: number
state?: string
}
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
scene.remove(mesh)
mesh.geometry.dispose()
disposeMaterial(mesh.material)
}
@@ -111,14 +112,74 @@ function createVoxelMesh(size: number, options: VoxelRendererOptions) {
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 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.multiplyScalar(1 + stateMix * 0.16)
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) {
disposeMesh(bundle.mesh)
disposeMesh(bundle.scene, bundle.mesh)
bundle.mesh = createVoxelMesh(size, options)
bundle.scene.add(bundle.mesh)
@@ -142,6 +203,7 @@ function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number,
const color = new THREE.Color()
const spacing = WORLD_SIZE / size
const origin = -WORLD_SIZE / 2 + spacing / 2
const statePalette = createStatePalette(cells, options)
let instanceIndex = 0
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.rotation.set(0, 0, 0)
dummy.updateMatrix()
colorForCell(bundle, cell, size, ruleId, color)
colorForCell(bundle, cell, size, ruleId, statePalette, color)
mesh.setMatrixAt(instanceIndex, dummy.matrix)
mesh.setColorAt(instanceIndex, color)
instanceIndex += 1
@@ -247,7 +309,7 @@ export function createVoxelRendererCore(targetNode: HTMLDivElement, options: Vox
dispose() {
window.cancelAnimationFrame(bundle.animationFrame)
bundle.resizeObserver.disconnect()
disposeMesh(bundle.mesh)
disposeMesh(bundle.scene, bundle.mesh)
bundle.bounds.geometry.dispose()
disposeMaterial(bundle.bounds.material)
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 { createCaStudioApi } from './caStudioApi.js';
import { loadEnv } from './loadEnv.js';
loadEnv();
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
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 name="viewport" content="width=device-width, initial-scale=1" />
<title>CA Studio Admin</title>
<script type="module" crossorigin src="/admin/assets/index-Bo_SPfbl.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-C7KqEE0-.css">
<script type="module" crossorigin src="/admin/assets/index-nVGDjB10.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-D7EhCe8j.css">
</head>
<body>
<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 { fileURLToPath } from 'node:url'
import { Pool } from 'pg'
import { loadEnv } from './load-env.mjs'
loadEnv()
const __dirname = dirname(fileURLToPath(import.meta.url))
const migrationsDir = resolve(__dirname, '../src/migrations')
@@ -10,6 +13,16 @@ const 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 {
const migrationFiles = (await readdir(migrationsDir))
.filter((file) => file.endsWith('.sql'))
@@ -21,6 +34,13 @@ try {
await pool.query(sql)
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 {
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 { createCaStudioApi } from './caStudioApi.js'
import { loadEnv } from './loadEnv.js'
loadEnv()
const databaseUrl = process.env.DATABASE_URL
+71 -1
View File
@@ -1,5 +1,5 @@
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'
function cells(width: number, height: number, alive: Array<[number, number]> = []): Cells {
@@ -28,6 +28,7 @@ describe('CA engine runtimes', () => {
'outer-totalistic-2d',
'elementary-1d',
'wildfire-2d',
'naga-3d',
'generic-voxel-ca',
'noop'
])
@@ -140,6 +141,75 @@ describe('CA engine runtimes', () => {
).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', () => {
const current = cells(3, 3, [
[2, 2],
@@ -8,8 +8,11 @@ import {
describe('CA initial condition runtimes', () => {
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('naga-markov')?.label).toBe('Naga Markov chains')
})
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]])
})
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', () => {
expect(() =>
registerInitialConditionRuntime({
+92
View File
@@ -4,6 +4,98 @@ import { createCaStudioApi } from '../src/caStudioApi.js'
import { createTestDb } from './testDb.js'
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 () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
+3 -4
View File
@@ -94,10 +94,9 @@ describe('CA Studio repository', () => {
supportedClasses: [{ neighborhoodId: 'von-neumann', dimensions: 2, states: 3 }]
})
expect((await listInitialConditionGenerators(db)).map((generator) => generator.slug)).toEqual([
'life-random-soup',
'von-neumann-traffic'
])
expect((await listInitialConditionGenerators(db)).map((generator) => generator.slug)).toEqual(
expect.arrayContaining(['life-random-soup', 'von-neumann-traffic', 'naga-markov-chains'])
)
expect(
(await listInitialConditionGenerators(db, {
caClass: { neighborhoodId: 'moore', dimensions: 2, states: 2 }