Adding first working version
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user