Adding first working version
This commit is contained in:
@@ -13,23 +13,28 @@ The original embeddable GlitchComponent has been moved to the sibling folder:
|
||||
Start the database:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
docker compose up -d
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
Create `.env` from the local example:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Run migrations:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test npm run migrate
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
Start the API:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test npm run dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
In another terminal, start the admin UI:
|
||||
In another terminal, start only the admin UI if needed:
|
||||
|
||||
```bash
|
||||
npm run admin:dev
|
||||
|
||||
@@ -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()
|
||||
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
function parseEnvLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#'))
|
||||
return null;
|
||||
const equalsIndex = trimmed.indexOf('=');
|
||||
if (equalsIndex === -1)
|
||||
return null;
|
||||
const key = trimmed.slice(0, equalsIndex).trim();
|
||||
let value = trimmed.slice(equalsIndex + 1).trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
||||
return null;
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
return { key, value };
|
||||
}
|
||||
function loadEnvFile(path) {
|
||||
if (!existsSync(path))
|
||||
return;
|
||||
const content = readFileSync(path, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const parsed = parseEnvLine(line);
|
||||
if (!parsed || process.env[parsed.key] !== undefined)
|
||||
continue;
|
||||
process.env[parsed.key] = parsed.value;
|
||||
}
|
||||
}
|
||||
export function loadEnv() {
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
const candidatePaths = [
|
||||
resolve(process.cwd(), '.env'),
|
||||
resolve(process.cwd(), '..', '.env'),
|
||||
resolve(moduleDir, '..', '.env'),
|
||||
resolve(moduleDir, '..', '..', '.env')
|
||||
];
|
||||
for (const path of [...new Set(candidatePaths)]) {
|
||||
loadEnvFile(path);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=loadEnv.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"loadEnv.js","sourceRoot":"","sources":["../src/loadEnv.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAClD,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAExC,SAAS,YAAY,CAAC,IAAY;IAChC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAA;IAC3B,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IAEpD,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;IACxC,IAAI,WAAW,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,CAAA;IAEnC,MAAM,GAAG,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC,IAAI,EAAE,CAAA;IAChD,IAAI,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,WAAW,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;IACjD,IAAI,CAAC,0BAA0B,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IAEtD,IACE,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QAC9C,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAC9C,CAAC;QACD,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC5B,CAAC;IAED,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,CAAA;AACvB,CAAC;AAED,SAAS,WAAW,CAAC,IAAY;IAC/B,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAM;IAE7B,MAAM,OAAO,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;IAC1C,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QAC1C,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,CAAA;QACjC,IAAI,CAAC,MAAM,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,SAAS;YAAE,SAAQ;QAC9D,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,KAAK,CAAA;IACxC,CAAC;AACH,CAAC;AAED,MAAM,UAAU,OAAO;IACrB,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;IACzD,MAAM,cAAc,GAAG;QACrB,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,MAAM,CAAC;QAC9B,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC;QACpC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,MAAM,CAAC;QAChC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC;KACvC,CAAA;IAED,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,cAAc,CAAC,CAAC,EAAE,CAAC;QAChD,WAAW,CAAC,IAAI,CAAC,CAAA;IACnB,CAAC;AACH,CAAC"}
|
||||
Vendored
+2
@@ -1,5 +1,7 @@
|
||||
import { Pool } from 'pg';
|
||||
import { createCaStudioApi } from './caStudioApi.js';
|
||||
import { loadEnv } from './loadEnv.js';
|
||||
loadEnv();
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required');
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AACzB,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAEpD,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAA;AAE5C,IAAI,CAAC,WAAW,EAAE,CAAC;IACjB,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAA;AAC7C,CAAC;AAED,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,EAAE,gBAAgB,EAAE,WAAW,EAAE,CAAC,CAAA;AACxD,MAAM,GAAG,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAA;AACnC,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAA;AAE7C,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,+CAA+C,IAAI,EAAE,CAAC,CAAA;AACpE,CAAC,CAAC,CAAA"}
|
||||
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AACzB,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAEtC,OAAO,EAAE,CAAA;AAET,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAA;AAE5C,IAAI,CAAC,WAAW,EAAE,CAAC;IACjB,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAA;AAC7C,CAAC;AAED,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,EAAE,gBAAgB,EAAE,WAAW,EAAE,CAAC,CAAA;AACxD,MAAM,GAAG,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAA;AACnC,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAA;AAE7C,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,+CAA+C,IAAI,EAAE,CAAC,CAAA;AACpE,CAAC,CAAC,CAAA"}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,8 +4,8 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CA Studio Admin</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-Bo_SPfbl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-C7KqEE0-.css">
|
||||
<script type="module" crossorigin src="/admin/assets/index-nVGDjB10.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-D7EhCe8j.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
function parseEnvLine(line) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) return null
|
||||
|
||||
const equalsIndex = trimmed.indexOf('=')
|
||||
if (equalsIndex === -1) return null
|
||||
|
||||
const key = trimmed.slice(0, equalsIndex).trim()
|
||||
let value = trimmed.slice(equalsIndex + 1).trim()
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
return { key, value }
|
||||
}
|
||||
|
||||
function loadEnvFile(path) {
|
||||
if (!existsSync(path)) return
|
||||
|
||||
const content = readFileSync(path, 'utf8')
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const parsed = parseEnvLine(line)
|
||||
if (!parsed || process.env[parsed.key] !== undefined) continue
|
||||
process.env[parsed.key] = parsed.value
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEnv() {
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url))
|
||||
const candidatePaths = [
|
||||
resolve(process.cwd(), '.env'),
|
||||
resolve(process.cwd(), '..', '.env'),
|
||||
resolve(moduleDir, '..', '.env'),
|
||||
resolve(moduleDir, '..', '..', '.env')
|
||||
]
|
||||
|
||||
for (const path of [...new Set(candidatePaths)]) {
|
||||
loadEnvFile(path)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { readdir, readFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Pool } from 'pg'
|
||||
import { loadEnv } from './load-env.mjs'
|
||||
|
||||
loadEnv()
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const migrationsDir = resolve(__dirname, '../src/migrations')
|
||||
@@ -10,6 +13,16 @@ const connectionString =
|
||||
|
||||
const pool = new Pool({ connectionString })
|
||||
|
||||
function redactConnectionString(value) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.password) url.password = '***'
|
||||
return url.toString()
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const migrationFiles = (await readdir(migrationsDir))
|
||||
.filter((file) => file.endsWith('.sql'))
|
||||
@@ -21,6 +34,13 @@ try {
|
||||
await pool.query(sql)
|
||||
console.log(`Applied migration ${migrationPath}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === 'ECONNREFUSED') {
|
||||
console.error(`Unable to connect to Postgres at ${redactConnectionString(connectionString)}`)
|
||||
console.error('For the local Docker database, start it with `docker compose up -d postgres` from the repo root.')
|
||||
console.error('Then use DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
function parseEnvLine(line: string) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) return null
|
||||
|
||||
const equalsIndex = trimmed.indexOf('=')
|
||||
if (equalsIndex === -1) return null
|
||||
|
||||
const key = trimmed.slice(0, equalsIndex).trim()
|
||||
let value = trimmed.slice(equalsIndex + 1).trim()
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
return { key, value }
|
||||
}
|
||||
|
||||
function loadEnvFile(path: string) {
|
||||
if (!existsSync(path)) return
|
||||
|
||||
const content = readFileSync(path, 'utf8')
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const parsed = parseEnvLine(line)
|
||||
if (!parsed || process.env[parsed.key] !== undefined) continue
|
||||
process.env[parsed.key] = parsed.value
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEnv() {
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url))
|
||||
const candidatePaths = [
|
||||
resolve(process.cwd(), '.env'),
|
||||
resolve(process.cwd(), '..', '.env'),
|
||||
resolve(moduleDir, '..', '.env'),
|
||||
resolve(moduleDir, '..', '..', '.env')
|
||||
]
|
||||
|
||||
for (const path of [...new Set(candidatePaths)]) {
|
||||
loadEnvFile(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
WITH elementary_rules(slug, name, description, rule_id) AS (
|
||||
VALUES
|
||||
(
|
||||
'elementary-rule-18',
|
||||
'Elementary Rule 18',
|
||||
'Sparse elementary 1D rule with nested, triangle-like growth from simple seeds.',
|
||||
'Rule 18'
|
||||
),
|
||||
(
|
||||
'elementary-rule-22',
|
||||
'Elementary Rule 22',
|
||||
'Elementary 1D rule known for irregular nested structures and chaotic-looking texture.',
|
||||
'Rule 22'
|
||||
),
|
||||
(
|
||||
'elementary-rule-45',
|
||||
'Elementary Rule 45',
|
||||
'Chaotic elementary 1D rule that produces asymmetric texture from small seeds.',
|
||||
'Rule 45'
|
||||
),
|
||||
(
|
||||
'elementary-rule-54',
|
||||
'Elementary Rule 54',
|
||||
'Particle-like elementary 1D rule with gliders and collision behavior.',
|
||||
'Rule 54'
|
||||
),
|
||||
(
|
||||
'elementary-rule-60',
|
||||
'Elementary Rule 60',
|
||||
'Additive elementary 1D rule that creates Sierpinski-style nested patterns.',
|
||||
'Rule 60'
|
||||
),
|
||||
(
|
||||
'elementary-rule-73',
|
||||
'Elementary Rule 73',
|
||||
'Elementary 1D rule with asymmetric localized structures and repeating motifs.',
|
||||
'Rule 73'
|
||||
),
|
||||
(
|
||||
'elementary-rule-90',
|
||||
'Elementary Rule 90',
|
||||
'Classic additive elementary 1D rule that generates the Sierpinski triangle.',
|
||||
'Rule 90'
|
||||
),
|
||||
(
|
||||
'elementary-rule-122',
|
||||
'Elementary Rule 122',
|
||||
'Elementary 1D rule with rich growth fronts and structured irregular regions.',
|
||||
'Rule 122'
|
||||
),
|
||||
(
|
||||
'elementary-rule-126',
|
||||
'Elementary Rule 126',
|
||||
'Expansive elementary 1D rule that quickly fills space with symmetric structure.',
|
||||
'Rule 126'
|
||||
),
|
||||
(
|
||||
'elementary-rule-150',
|
||||
'Elementary Rule 150',
|
||||
'Additive elementary 1D rule combining left, center, and right parity.',
|
||||
'Rule 150'
|
||||
),
|
||||
(
|
||||
'elementary-rule-184',
|
||||
'Elementary Rule 184',
|
||||
'Traffic-flow elementary 1D rule often used to model particles moving through a lane.',
|
||||
'Rule 184'
|
||||
)
|
||||
)
|
||||
INSERT INTO ca_engines (
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
engine_kind,
|
||||
license,
|
||||
owner,
|
||||
ip_notice,
|
||||
supported_classes,
|
||||
params_schema,
|
||||
default_params
|
||||
)
|
||||
SELECT
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
'elementary-1d',
|
||||
'ruleset-reference',
|
||||
'Glitch University',
|
||||
'Registry metadata for elementary CA rules; custom engine implementations may carry separate licensing.',
|
||||
'[{"dimensions":1,"states":2}]'::jsonb,
|
||||
jsonb_build_object(
|
||||
'ruleId',
|
||||
jsonb_build_object(
|
||||
'type', 'select',
|
||||
'label', 'Rule',
|
||||
'default', rule_id,
|
||||
'options', jsonb_build_array(jsonb_build_object('value', rule_id, 'label', rule_id))
|
||||
),
|
||||
'rendererId',
|
||||
jsonb_build_object(
|
||||
'type', 'select',
|
||||
'label', 'Renderer',
|
||||
'default', 'elementary-1d',
|
||||
'options', '[{"value":"elementary-1d","label":"Elementary 1D"}]'::jsonb
|
||||
)
|
||||
),
|
||||
jsonb_build_object('ruleId', rule_id, 'rendererId', 'elementary-1d')
|
||||
FROM elementary_rules
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
@@ -0,0 +1,43 @@
|
||||
INSERT INTO ca_engines (
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
engine_kind,
|
||||
license,
|
||||
owner,
|
||||
ip_notice,
|
||||
supported_classes,
|
||||
params_schema,
|
||||
default_params
|
||||
)
|
||||
VALUES (
|
||||
'naga-3d',
|
||||
'Naga 3D',
|
||||
'Sparse 3D directional moykle rule that follows head-tail chains and moves tail symbols through voxel space.',
|
||||
'naga-3d',
|
||||
'internal',
|
||||
'Glitch University',
|
||||
'Experimental Naga engine implementation. Do not redistribute without the Glitch University CA engine licence.',
|
||||
'[{"dimensions":3,"states":7}]'::jsonb,
|
||||
'{
|
||||
"ruleId": {
|
||||
"type": "select",
|
||||
"label": "Rule",
|
||||
"default": "naga-3d",
|
||||
"options": [{"value":"naga-3d","label":"3D Naga"}]
|
||||
},
|
||||
"rendererId": {
|
||||
"type": "select",
|
||||
"label": "Renderer",
|
||||
"default": "voxel-3d",
|
||||
"options": [{"value":"voxel-3d","label":"Voxel 3D"}]
|
||||
},
|
||||
"seed": {
|
||||
"type": "string",
|
||||
"label": "Seed",
|
||||
"default": "naga"
|
||||
}
|
||||
}'::jsonb,
|
||||
'{"ruleId":"naga-3d","rendererId":"voxel-3d","seed":"naga"}'::jsonb
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
@@ -0,0 +1,49 @@
|
||||
INSERT INTO ca_initial_condition_generators (
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
generator_kind,
|
||||
supported_classes,
|
||||
params_schema,
|
||||
default_params
|
||||
)
|
||||
VALUES (
|
||||
'naga-markov-chains',
|
||||
'Naga Markov chains',
|
||||
'Generates collision-free 3D Naga moykle chains. Each next moykle is placed at the previous position plus its directional state, with an adjustable probability of repeating the same state.',
|
||||
'naga-markov',
|
||||
'[{"dimensions":3,"states":7}]'::jsonb,
|
||||
'{
|
||||
"count": {
|
||||
"type": "number",
|
||||
"label": "Nagas",
|
||||
"default": 3,
|
||||
"min": 1,
|
||||
"max": 64,
|
||||
"step": 1
|
||||
},
|
||||
"length": {
|
||||
"type": "number",
|
||||
"label": "Length",
|
||||
"default": 5,
|
||||
"min": 1,
|
||||
"max": 64,
|
||||
"step": 1
|
||||
},
|
||||
"sameTypeProbability": {
|
||||
"type": "range",
|
||||
"label": "Same type probability",
|
||||
"default": 0.8,
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"step": 0.01
|
||||
},
|
||||
"seed": {
|
||||
"type": "string",
|
||||
"label": "Seed",
|
||||
"default": "naga-markov"
|
||||
}
|
||||
}'::jsonb,
|
||||
'{"count":3,"length":5,"sameTypeProbability":0.8,"seed":"naga-markov"}'::jsonb
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
@@ -0,0 +1,13 @@
|
||||
UPDATE ca_engines
|
||||
SET
|
||||
params_schema = params_schema || jsonb_build_object(
|
||||
'edgeWrap',
|
||||
jsonb_build_object(
|
||||
'type', 'boolean',
|
||||
'label', 'Edge wrap',
|
||||
'default', true,
|
||||
'group', 'Edge settings'
|
||||
)
|
||||
),
|
||||
default_params = default_params || jsonb_build_object('edgeWrap', true)
|
||||
WHERE slug = 'naga-3d';
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Pool } from 'pg'
|
||||
import { createCaStudioApi } from './caStudioApi.js'
|
||||
import { loadEnv } from './loadEnv.js'
|
||||
|
||||
loadEnv()
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { listCaEngineRuntimes, registerCaEngineRuntime, stepCaCells } from '../admin/src/caEngines.js'
|
||||
import { listCaEngineRuntimes, registerCaEngineRuntime, stepCaCells, stepCaSimulation } from '../admin/src/caEngines.js'
|
||||
import type { Cells, SceneParams } from '../admin/src/types.js'
|
||||
|
||||
function cells(width: number, height: number, alive: Array<[number, number]> = []): Cells {
|
||||
@@ -28,6 +28,7 @@ describe('CA engine runtimes', () => {
|
||||
'outer-totalistic-2d',
|
||||
'elementary-1d',
|
||||
'wildfire-2d',
|
||||
'naga-3d',
|
||||
'generic-voxel-ca',
|
||||
'noop'
|
||||
])
|
||||
@@ -140,6 +141,75 @@ describe('CA engine runtimes', () => {
|
||||
).toEqual(cells(3, 3))
|
||||
})
|
||||
|
||||
it('steps Naga 3D sparse coordinates through the shared grid projection contract', () => {
|
||||
const current = cells(5, 5)
|
||||
const params = settings({
|
||||
caClass: { dimensions: 3, states: 7 },
|
||||
renderer: { id: 'voxel-3d' },
|
||||
simulation: {
|
||||
engineId: 'naga-3d',
|
||||
ruleId: 'naga-3d',
|
||||
grid: { size: [5, 5, 5], boundary: 'wrap' },
|
||||
initialCondition: {
|
||||
type: 'cells',
|
||||
cells: [[2, 2, 2, 1]]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const stepped = stepCaSimulation(current, params)
|
||||
|
||||
expect(stepped.cells).toEqual(cells(5, 5, [[1, 2]]))
|
||||
expect(params.simulation?.initialCondition?.cells).toEqual([[2, 2, 2, 1]])
|
||||
expect(params.simulation?.nagaTick).toBeUndefined()
|
||||
expect(stepped.settings.simulation?.initialCondition?.cells).toEqual([[1, 2, 2, 1]])
|
||||
expect(stepped.settings.simulation?.nagaTick).toBe(1)
|
||||
})
|
||||
|
||||
it('wraps Naga 3D sparse coordinates before computing voxel addresses', () => {
|
||||
const current = cells(5, 5)
|
||||
const params = settings({
|
||||
caClass: { dimensions: 3, states: 7 },
|
||||
renderer: { id: 'voxel-3d' },
|
||||
simulation: {
|
||||
engineId: 'naga-3d',
|
||||
ruleId: 'naga-3d',
|
||||
grid: { size: [5, 5, 5], boundary: 'wrap' },
|
||||
initialCondition: {
|
||||
type: 'cells',
|
||||
cells: [[4, 2, 2, 4]]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const stepped = stepCaSimulation(current, params)
|
||||
|
||||
expect(stepped.settings.simulation?.initialCondition?.cells).toEqual([[0, 2, 2, 4]])
|
||||
expect(stepped.cells).toEqual(cells(5, 5, [[0, 2]]))
|
||||
})
|
||||
|
||||
it('keeps Naga 3D sparse coordinates in place when fixed boundaries would leave the grid', () => {
|
||||
const current = cells(5, 5)
|
||||
const params = settings({
|
||||
caClass: { dimensions: 3, states: 7 },
|
||||
renderer: { id: 'voxel-3d' },
|
||||
simulation: {
|
||||
engineId: 'naga-3d',
|
||||
ruleId: 'naga-3d',
|
||||
grid: { size: [5, 5, 5], boundary: 'fixed' },
|
||||
initialCondition: {
|
||||
type: 'cells',
|
||||
cells: [[4, 2, 2, 4]]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const stepped = stepCaSimulation(current, params)
|
||||
|
||||
expect(stepped.settings.simulation?.initialCondition?.cells).toEqual([[4, 2, 2, 4]])
|
||||
expect(stepped.cells).toEqual(cells(5, 5, [[4, 2]]))
|
||||
})
|
||||
|
||||
it('defaults boundaries to wrap when the node does not specify a boundary condition', () => {
|
||||
const current = cells(3, 3, [
|
||||
[2, 2],
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
|
||||
describe('CA initial condition runtimes', () => {
|
||||
it('registers the built-in random soup generator', () => {
|
||||
expect(listInitialConditionRuntimes().map((runtime) => runtime.id)).toContain('random-soup')
|
||||
expect(listInitialConditionRuntimes().map((runtime) => runtime.id)).toEqual(
|
||||
expect.arrayContaining(['random-soup', 'naga-markov'])
|
||||
)
|
||||
expect(getInitialConditionRuntime('random-soup')?.label).toBe('Random soup')
|
||||
expect(getInitialConditionRuntime('naga-markov')?.label).toBe('Naga Markov chains')
|
||||
})
|
||||
|
||||
it('generates deterministic cell coordinates for the same seed and density', () => {
|
||||
@@ -47,6 +50,49 @@ describe('CA initial condition runtimes', () => {
|
||||
}).cells).toEqual([[1, 1, 0]])
|
||||
})
|
||||
|
||||
it('generates collision-free Naga Markov chains with directional continuity', () => {
|
||||
const generated = generateInitialCondition('naga-markov', {
|
||||
caClass: { dimensions: 3, states: 7 },
|
||||
settings: { simulation: { grid: { size: [12, 12, 12] } } },
|
||||
params: { count: 2, length: 5, sameTypeProbability: 1, seed: 'straight-nagas' }
|
||||
})
|
||||
|
||||
expect(generated.cells).toHaveLength(10)
|
||||
expect(new Set(generated.cells.map(([x, y, z]) => `${x}_${y}_${z}`)).size).toBe(10)
|
||||
expect(generated.generator).toMatchObject({
|
||||
kind: 'naga-markov',
|
||||
count: 2,
|
||||
length: 5,
|
||||
sameTypeProbability: 1,
|
||||
seed: 'straight-nagas',
|
||||
created: 2
|
||||
})
|
||||
|
||||
const occupied = new Set(generated.cells.map(([x, y, z]) => `${x}_${y}_${z}`))
|
||||
for (let start = 0; start < generated.cells.length; start += 5) {
|
||||
for (let index = start; index < start + 4; index += 1) {
|
||||
const [x, y, z, arrow] = generated.cells[index]
|
||||
const [nextX, nextY, nextZ, nextArrow] = generated.cells[index + 1]
|
||||
expect(nextArrow).toBe(arrow)
|
||||
if (arrow === 1) expect([nextX, nextY, nextZ]).toEqual([x + 1, y, z])
|
||||
if (arrow === 2) expect([nextX, nextY, nextZ]).toEqual([x, y + 1, z])
|
||||
if (arrow === 3) expect([nextX, nextY, nextZ]).toEqual([x, y, z + 1])
|
||||
if (arrow === 4) expect([nextX, nextY, nextZ]).toEqual([x - 1, y, z])
|
||||
if (arrow === 5) expect([nextX, nextY, nextZ]).toEqual([x, y - 1, z])
|
||||
if (arrow === 6) expect([nextX, nextY, nextZ]).toEqual([x, y, z - 1])
|
||||
}
|
||||
const [x, y, z, arrow] = generated.cells[start + 4]
|
||||
const terminalTarget =
|
||||
arrow === 1 ? [x + 1, y, z] :
|
||||
arrow === 2 ? [x, y + 1, z] :
|
||||
arrow === 3 ? [x, y, z + 1] :
|
||||
arrow === 4 ? [x - 1, y, z] :
|
||||
arrow === 5 ? [x, y - 1, z] :
|
||||
[x, y, z - 1]
|
||||
expect(occupied.has(terminalTarget.join('_'))).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects duplicate runtime ids and missing implementations', () => {
|
||||
expect(() =>
|
||||
registerInitialConditionRuntime({
|
||||
|
||||
@@ -4,6 +4,98 @@ import { createCaStudioApi } from '../src/caStudioApi.js'
|
||||
import { createTestDb } from './testDb.js'
|
||||
|
||||
describe('CA Studio API', () => {
|
||||
it('seeds a useful shelf of elementary 1D CA rules', async () => {
|
||||
const db = await createTestDb()
|
||||
const app = createCaStudioApi(db)
|
||||
|
||||
await request(app)
|
||||
.get('/api/ca/engines?dimensions=1&states=2')
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
const rules = response.body
|
||||
.filter((engine: { engine_kind: string }) => engine.engine_kind === 'elementary-1d')
|
||||
.map((engine: { default_params: { ruleId?: string } }) => engine.default_params.ruleId)
|
||||
|
||||
expect(rules).toEqual(expect.arrayContaining([
|
||||
'Rule 18',
|
||||
'Rule 22',
|
||||
'Rule 30',
|
||||
'Rule 45',
|
||||
'Rule 54',
|
||||
'Rule 60',
|
||||
'Rule 73',
|
||||
'Rule 90',
|
||||
'Rule 110',
|
||||
'Rule 122',
|
||||
'Rule 126',
|
||||
'Rule 150',
|
||||
'Rule 184'
|
||||
]))
|
||||
|
||||
const rule90 = response.body.find((engine: { slug: string }) => engine.slug === 'elementary-rule-90')
|
||||
expect(rule90.params_schema.ruleId).toMatchObject({
|
||||
type: 'select',
|
||||
default: 'Rule 90',
|
||||
options: [{ value: 'Rule 90', label: 'Rule 90' }]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds the Naga 3D engine for seven-state voxel spaces', async () => {
|
||||
const db = await createTestDb()
|
||||
const app = createCaStudioApi(db)
|
||||
|
||||
await request(app)
|
||||
.get('/api/ca/engines?dimensions=3&states=7')
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
const naga = response.body.find((engine: { slug: string }) => engine.slug === 'naga-3d')
|
||||
expect(naga).toMatchObject({
|
||||
name: 'Naga 3D',
|
||||
engine_kind: 'naga-3d',
|
||||
default_params: {
|
||||
ruleId: 'naga-3d',
|
||||
rendererId: 'voxel-3d',
|
||||
seed: 'naga',
|
||||
edgeWrap: true
|
||||
}
|
||||
})
|
||||
expect(naga.params_schema.ruleId.options).toEqual([{ value: 'naga-3d', label: '3D Naga' }])
|
||||
expect(naga.params_schema.edgeWrap).toMatchObject({
|
||||
type: 'boolean',
|
||||
label: 'Edge wrap',
|
||||
default: true,
|
||||
group: 'Edge settings'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds the Naga Markov initial condition generator for seven-state voxel spaces', async () => {
|
||||
const db = await createTestDb()
|
||||
const app = createCaStudioApi(db)
|
||||
|
||||
await request(app)
|
||||
.get('/api/ca/initial-condition-generators?dimensions=3&states=7')
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
const generator = response.body.find((item: { slug: string }) => item.slug === 'naga-markov-chains')
|
||||
expect(generator).toMatchObject({
|
||||
name: 'Naga Markov chains',
|
||||
generator_kind: 'naga-markov',
|
||||
default_params: {
|
||||
count: 3,
|
||||
length: 5,
|
||||
sameTypeProbability: 0.8,
|
||||
seed: 'naga-markov'
|
||||
}
|
||||
})
|
||||
expect(generator.params_schema.sameTypeProbability).toMatchObject({
|
||||
type: 'range',
|
||||
default: 0.8
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('lists preset nodes whose resolved settings use an engine', async () => {
|
||||
const db = await createTestDb()
|
||||
const app = createCaStudioApi(db)
|
||||
|
||||
@@ -94,10 +94,9 @@ describe('CA Studio repository', () => {
|
||||
supportedClasses: [{ neighborhoodId: 'von-neumann', dimensions: 2, states: 3 }]
|
||||
})
|
||||
|
||||
expect((await listInitialConditionGenerators(db)).map((generator) => generator.slug)).toEqual([
|
||||
'life-random-soup',
|
||||
'von-neumann-traffic'
|
||||
])
|
||||
expect((await listInitialConditionGenerators(db)).map((generator) => generator.slug)).toEqual(
|
||||
expect.arrayContaining(['life-random-soup', 'von-neumann-traffic', 'naga-markov-chains'])
|
||||
)
|
||||
expect(
|
||||
(await listInitialConditionGenerators(db, {
|
||||
caClass: { neighborhoodId: 'moore', dimensions: 2, states: 2 }
|
||||
|
||||
Reference in New Issue
Block a user