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()