Adding the lab project

This commit is contained in:
2026-07-01 12:10:12 +02:00
commit 48ce52b52c
56 changed files with 22289 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CA Studio Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+538
View File
@@ -0,0 +1,538 @@
import React from 'react'
import { getCaClassFromParams, supportsCaClass } from './caRuntime.js'
import type { CaClass, Cells, SceneParams } from './types.js'
import { Voxel3DRenderer } from './voxel3d/Voxel3DRenderer.js'
interface CaRendererProps {
caption: string
cells: Cells
settings: SceneParams
onCellsChange: (cells: Cells) => void
}
export interface CaRendererRuntime {
id: string
label: string
aliases?: string[]
supportedClasses: CaClass[]
Component: React.ComponentType<CaRendererProps>
}
const rendererRuntimes = new Map<string, CaRendererRuntime>()
export function registerCaRendererRuntime(runtime: CaRendererRuntime, options: { replace?: boolean } = {}) {
if (!runtime.id.trim()) throw new Error('CA renderer runtime id is required')
if (rendererRuntimes.has(runtime.id) && !options.replace) {
throw new Error(`CA renderer runtime already registered: ${runtime.id}`)
}
rendererRuntimes.set(runtime.id, runtime)
}
export function listCaRendererRuntimes() {
return [...rendererRuntimes.values()]
}
export function listCompatibleCaRendererRuntimes(caClass: CaClass | undefined) {
return listCaRendererRuntimes().filter((runtime) => supportsCaClass(runtime, caClass))
}
export function getCaRendererRuntime(rendererId: string) {
return (
rendererRuntimes.get(rendererId) ??
[...rendererRuntimes.values()].find((runtime) => runtime.aliases?.includes(rendererId)) ??
null
)
}
export function CaRenderer({ caption, cells, settings, onCellsChange }: CaRendererProps) {
const rendererId = settings.renderer?.id ?? '2d-canvas'
const runtime = getCaRendererRuntime(rendererId)
const caClass = getCaClassFromParams(settings)
if (runtime && (!caClass || supportsCaClass(runtime, caClass))) {
const RendererComponent = runtime.Component
return <RendererComponent caption={caption} cells={cells} settings={settings} onCellsChange={onCellsChange} />
}
return (
<div className="board-wrap">
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
<div className="renderer-empty">
{runtime ? `Renderer ${rendererId} does not support this CA space` : `Renderer unavailable: ${rendererId}`}
</div>
</div>
)
}
function Voxel3DRendererAdapter({ caption, settings }: CaRendererProps) {
return <Voxel3DRenderer caption={caption} settings={settings} />
}
function rendererNumber(value: unknown, fallback: number, min: number, max: number) {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback
}
function parseElementaryRule(settings: SceneParams) {
const rendererRule = settings.renderer?.elementaryRule
if (typeof rendererRule === 'number') return Math.max(0, Math.min(255, Math.floor(rendererRule)))
const ruleId = settings.simulation?.ruleId ?? ''
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 nextElementaryRow(row: boolean[], rule: number) {
return row.map((_, index) => {
const left = row[(index - 1 + row.length) % row.length] ? 1 : 0
const center = row[index] ? 1 : 0
const right = row[(index + 1) % row.length] ? 1 : 0
const pattern = (left << 2) | (center << 1) | right
return ((rule >> pattern) & 1) === 1
})
}
function firstLiveRowIndex(cells: Cells) {
const index = cells.findIndex((row) => row.some(Boolean))
return index === -1 ? 0 : index
}
function Canvas2DRenderer({
caption,
cells,
onCellsChange
}: {
caption: string
cells: Cells
onCellsChange: (cells: Cells) => void
}) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
const cellsRef = React.useRef(cells)
const paintValueRef = React.useRef<boolean | null>(null)
const [canvasRevision, setCanvasRevision] = React.useState(0)
const width = cells[0]?.length ?? 0
const height = cells.length
React.useEffect(() => {
cellsRef.current = cells
}, [cells])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const observer = new ResizeObserver(() => setCanvasRevision((revision) => revision + 1))
observer.observe(canvas)
return () => observer.disconnect()
}, [])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas || width === 0 || height === 0) return
const context = canvas.getContext('2d')
if (!context) return
const rect = canvas.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
}
context.setTransform(dpr, 0, 0, dpr, 0, 0)
context.clearRect(0, 0, rect.width, rect.height)
context.fillStyle = '#071206'
context.fillRect(0, 0, rect.width, rect.height)
const cellSize = Math.min(rect.width / width, rect.height / height)
const boardWidth = cellSize * width
const boardHeight = cellSize * height
const offsetX = (rect.width - boardWidth) / 2
const offsetY = (rect.height - boardHeight) / 2
const gap = Math.max(1, Math.min(2, cellSize * 0.08))
context.strokeStyle = '#1e3519'
context.lineWidth = 1
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const px = offsetX + x * cellSize + gap / 2
const py = offsetY + y * cellSize + gap / 2
const size = Math.max(1, cellSize - gap)
if (cells[y][x]) {
context.fillStyle = '#70f45f'
context.shadowColor = 'rgba(112, 244, 95, 0.42)'
context.shadowBlur = Math.max(0, cellSize * 0.38)
context.fillRect(px, py, size, size)
context.shadowBlur = 0
context.strokeStyle = '#b7ffab'
} else {
context.fillStyle = '#071206'
context.fillRect(px, py, size, size)
context.strokeStyle = '#1e3519'
}
context.strokeRect(px + 0.5, py + 0.5, Math.max(0, size - 1), Math.max(0, size - 1))
}
}
}, [canvasRevision, cells, height, width])
function cellFromPointer(event: React.PointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current
if (!canvas || width === 0 || height === 0) return null
const rect = canvas.getBoundingClientRect()
const cellSize = Math.min(rect.width / width, rect.height / height)
const boardWidth = cellSize * width
const boardHeight = cellSize * height
const offsetX = (rect.width - boardWidth) / 2
const offsetY = (rect.height - boardHeight) / 2
const x = Math.floor((event.clientX - rect.left - offsetX) / cellSize)
const y = Math.floor((event.clientY - rect.top - offsetY) / cellSize)
if (x < 0 || y < 0 || x >= width || y >= height) return null
return { x, y }
}
function paintCell(x: number, y: number, value: boolean) {
const current = cellsRef.current
if (current[y]?.[x] === value) return
const next = current.map((row, rowIndex) =>
rowIndex === y ? row.map((alive, columnIndex) => (columnIndex === x ? value : alive)) : row
)
cellsRef.current = next
onCellsChange(next)
}
function handlePointerDown(event: React.PointerEvent<HTMLCanvasElement>) {
const cell = cellFromPointer(event)
if (!cell) return
event.currentTarget.setPointerCapture(event.pointerId)
const nextValue = !cellsRef.current[cell.y][cell.x]
paintValueRef.current = nextValue
paintCell(cell.x, cell.y, nextValue)
}
function handlePointerMove(event: React.PointerEvent<HTMLCanvasElement>) {
if (paintValueRef.current === null) return
const cell = cellFromPointer(event)
if (!cell) return
paintCell(cell.x, cell.y, paintValueRef.current)
}
function stopPainting(event: React.PointerEvent<HTMLCanvasElement>) {
paintValueRef.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
return (
<div className="board-wrap">
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
<canvas
ref={canvasRef}
aria-label="Game of Life cell editor"
className="board-canvas"
role="img"
onPointerCancel={stopPainting}
onPointerDown={handlePointerDown}
onPointerLeave={stopPainting}
onPointerMove={handlePointerMove}
onPointerUp={stopPainting}
/>
</div>
)
}
function Elementary1DRenderer({ caption, cells, settings, onCellsChange }: CaRendererProps) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
const cellsRef = React.useRef(cells)
const [canvasRevision, setCanvasRevision] = React.useState(0)
const width = cells[0]?.length ?? 0
const sourceRowIndex = Math.min(cells.length - 1, Math.max(0, firstLiveRowIndex(cells)))
const sourceRow = cells[sourceRowIndex] ?? []
const rule = parseElementaryRule(settings)
const historyRows = rendererNumber(settings.renderer?.historyRows, 96, 8, 512)
React.useEffect(() => {
cellsRef.current = cells
}, [cells])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const observer = new ResizeObserver(() => setCanvasRevision((revision) => revision + 1))
observer.observe(canvas)
return () => observer.disconnect()
}, [])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas || width === 0 || sourceRow.length === 0) return
const context = canvas.getContext('2d')
if (!context) return
const rect = canvas.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
}
context.setTransform(dpr, 0, 0, dpr, 0, 0)
context.clearRect(0, 0, rect.width, rect.height)
context.fillStyle = '#061006'
context.fillRect(0, 0, rect.width, rect.height)
const cellWidth = rect.width / width
const cellHeight = rect.height / historyRows
let row = [...sourceRow]
for (let y = 0; y < historyRows; y += 1) {
const age = y / Math.max(1, historyRows - 1)
for (let x = 0; x < width; x += 1) {
if (row[x]) {
const green = Math.round(190 + age * 55)
const red = Math.round(112 + age * 80)
context.fillStyle = `rgb(${red} ${green} 92)`
context.fillRect(x * cellWidth, y * cellHeight, Math.ceil(cellWidth), Math.ceil(cellHeight))
}
}
row = nextElementaryRow(row, rule)
}
context.strokeStyle = 'rgba(240, 201, 74, 0.32)'
context.lineWidth = 1
context.strokeRect(0.5, 0.5, Math.max(0, rect.width - 1), Math.max(0, rect.height - 1))
}, [canvasRevision, historyRows, rule, sourceRow, width])
function toggleColumn(event: React.PointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current
if (!canvas || width === 0) return
const rect = canvas.getBoundingClientRect()
const x = Math.floor(((event.clientX - rect.left) / rect.width) * width)
if (x < 0 || x >= width) return
const current = cellsRef.current
const next = current.map((row, rowIndex) =>
rowIndex === sourceRowIndex ? row.map((alive, columnIndex) => (columnIndex === x ? !alive : alive)) : row
)
cellsRef.current = next
onCellsChange(next)
}
return (
<div className="board-wrap elementary-renderer-wrap">
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
<canvas
ref={canvasRef}
aria-label={`Elementary cellular automaton rule ${rule}`}
className="elementary-canvas"
role="img"
onPointerDown={toggleColumn}
/>
</div>
)
}
function Wildfire2DRenderer({ caption, cells, onCellsChange }: CaRendererProps) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
const cellsRef = React.useRef(cells)
const paintValueRef = React.useRef<boolean | null>(null)
const [canvasRevision, setCanvasRevision] = React.useState(0)
const width = cells[0]?.length ?? 0
const height = cells.length
React.useEffect(() => {
cellsRef.current = cells
}, [cells])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const observer = new ResizeObserver(() => setCanvasRevision((revision) => revision + 1))
observer.observe(canvas)
return () => observer.disconnect()
}, [])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas || width === 0 || height === 0) return
const context = canvas.getContext('2d')
if (!context) return
const rect = canvas.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
}
context.setTransform(dpr, 0, 0, dpr, 0, 0)
context.clearRect(0, 0, rect.width, rect.height)
const cellSize = Math.min(rect.width / width, rect.height / height)
const boardWidth = cellSize * width
const boardHeight = cellSize * height
const offsetX = (rect.width - boardWidth) / 2
const offsetY = (rect.height - boardHeight) / 2
context.fillStyle = '#061006'
context.fillRect(0, 0, rect.width, rect.height)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const px = offsetX + x * cellSize
const py = offsetY + y * cellSize
if (cells[y][x]) {
const gradient = context.createRadialGradient(
px + cellSize * 0.5,
py + cellSize * 0.52,
cellSize * 0.08,
px + cellSize * 0.5,
py + cellSize * 0.52,
cellSize * 0.62
)
gradient.addColorStop(0, '#fff5b9')
gradient.addColorStop(0.45, '#f0c94a')
gradient.addColorStop(1, '#d9481e')
context.fillStyle = gradient
} else {
context.fillStyle = (x + y) % 2 === 0 ? '#143f1b' : '#0d2b14'
}
context.fillRect(px, py, Math.ceil(cellSize), Math.ceil(cellSize))
}
}
}, [canvasRevision, cells, height, width])
function cellFromPointer(event: React.PointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current
if (!canvas || width === 0 || height === 0) return null
const rect = canvas.getBoundingClientRect()
const cellSize = Math.min(rect.width / width, rect.height / height)
const boardWidth = cellSize * width
const boardHeight = cellSize * height
const offsetX = (rect.width - boardWidth) / 2
const offsetY = (rect.height - boardHeight) / 2
const x = Math.floor((event.clientX - rect.left - offsetX) / cellSize)
const y = Math.floor((event.clientY - rect.top - offsetY) / cellSize)
if (x < 0 || y < 0 || x >= width || y >= height) return null
return { x, y }
}
function paintCell(x: number, y: number, value: boolean) {
const current = cellsRef.current
if (current[y]?.[x] === value) return
const next = current.map((row, rowIndex) =>
rowIndex === y ? row.map((alive, columnIndex) => (columnIndex === x ? value : alive)) : row
)
cellsRef.current = next
onCellsChange(next)
}
function handlePointerDown(event: React.PointerEvent<HTMLCanvasElement>) {
const cell = cellFromPointer(event)
if (!cell) return
event.currentTarget.setPointerCapture(event.pointerId)
const nextValue = !cellsRef.current[cell.y][cell.x]
paintValueRef.current = nextValue
paintCell(cell.x, cell.y, nextValue)
}
function handlePointerMove(event: React.PointerEvent<HTMLCanvasElement>) {
if (paintValueRef.current === null) return
const cell = cellFromPointer(event)
if (!cell) return
paintCell(cell.x, cell.y, paintValueRef.current)
}
function stopPainting(event: React.PointerEvent<HTMLCanvasElement>) {
paintValueRef.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
return (
<div className="board-wrap wildfire-renderer-wrap">
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
<canvas
ref={canvasRef}
aria-label="Wildfire cellular automaton editor"
className="wildfire-canvas"
role="img"
onPointerCancel={stopPainting}
onPointerDown={handlePointerDown}
onPointerLeave={stopPainting}
onPointerMove={handlePointerMove}
onPointerUp={stopPainting}
/>
</div>
)
}
registerCaRendererRuntime(
{
id: '2d-canvas',
label: '2D Canvas',
supportedClasses: [{ dimensions: 2, states: 2 }],
Component: Canvas2DRenderer
},
{ replace: true }
)
registerCaRendererRuntime(
{
id: 'elementary-1d',
label: 'Elementary 1D',
aliases: ['elementary-ca', '1d-canvas'],
supportedClasses: [{ dimensions: 1, states: 2 }],
Component: Elementary1DRenderer
},
{ replace: true }
)
registerCaRendererRuntime(
{
id: 'wildfire-2d',
label: 'Wildfire 2D',
aliases: ['forest-fire-2d'],
supportedClasses: [{ dimensions: 2, states: 2 }],
Component: Wildfire2DRenderer
},
{ replace: true }
)
registerCaRendererRuntime(
{
id: 'voxel-3d',
label: 'Voxel 3D',
aliases: ['three-voxel'],
supportedClasses: [{ dimensions: 3, states: 2 }],
Component: Voxel3DRendererAdapter
},
{ replace: true }
)
+113
View File
@@ -0,0 +1,113 @@
import React from 'react'
import type { PresetNode } from './types.js'
interface PresetAssetTreeProps {
assignedNodeId?: string
nodes: PresetNode[]
selectedNodeId?: string
onSelect: (node: PresetNode) => void
}
interface TreeItem {
node: PresetNode
children: TreeItem[]
}
function compareNodes(left: PresetNode, right: PresetNode) {
return left.sort_order - right.sort_order || left.name.localeCompare(right.name)
}
function buildTree(nodes: PresetNode[]) {
const byParent = new Map<string | null, PresetNode[]>()
for (const node of nodes) {
const siblings = byParent.get(node.parent_id) ?? []
siblings.push(node)
byParent.set(node.parent_id, siblings)
}
function build(parentId: string | null): TreeItem[] {
return (byParent.get(parentId) ?? [])
.sort(compareNodes)
.map((node) => ({ node, children: build(node.id) }))
}
return build(null)
}
export function PresetAssetTree({
assignedNodeId,
nodes,
selectedNodeId,
onSelect
}: PresetAssetTreeProps) {
const tree = React.useMemo(() => buildTree(nodes), [nodes])
if (tree.length === 0) return <p className="empty">This CA library has no presets.</p>
return (
<div className="asset-tree">
{tree.map((item) => (
<AssetTreeItem
assignedNodeId={assignedNodeId}
item={item}
key={item.node.id}
selectedNodeId={selectedNodeId}
onSelect={onSelect}
/>
))}
</div>
)
}
function AssetTreeItem({
assignedNodeId,
item,
selectedNodeId,
onSelect
}: {
assignedNodeId?: string
item: TreeItem
selectedNodeId?: string
onSelect: (node: PresetNode) => void
}) {
const [open, setOpen] = React.useState(true)
const selected = item.node.id === selectedNodeId
const assigned = item.node.id === assignedNodeId
return (
<div className="asset-tree-item">
<div className={`asset-tree-row${selected ? ' selected' : ''}${assigned ? ' assigned' : ''}`}>
<button
aria-label={item.children.length ? `${open ? 'Collapse' : 'Expand'} ${item.node.name}` : `${item.node.name} has no children`}
className="asset-tree-arrow"
disabled={!item.children.length}
type="button"
onClick={() => setOpen((current) => !current)}
>
{item.children.length ? (open ? '▾' : '▸') : '·'}
</button>
<button className="asset-tree-select" type="button" onClick={() => onSelect(item.node)}>
<strong>{item.node.name}</strong>
<small>
{item.node.kind}
{assigned ? ' · current' : ''}
</small>
{item.node.description ? <span>{item.node.description}</span> : null}
</button>
</div>
{open && item.children.length ? (
<div className="asset-tree-children">
{item.children.map((child) => (
<AssetTreeItem
assignedNodeId={assignedNodeId}
item={child}
key={child.node.id}
selectedNodeId={selectedNodeId}
onSelect={onSelect}
/>
))}
</div>
) : null}
</div>
)
}
+679
View File
@@ -0,0 +1,679 @@
import React from 'react'
import { getCaClassFromParams, supportsCaClass } from './caRuntime.js'
import type { CaEngine, JsonObject, PresetNode } from './types.js'
interface PresetNodeTreeProps {
assignedNodeId?: string
engines?: CaEngine[]
nodes: PresetNode[]
selectedNodeId?: string
usedNodeIds: Set<string>
onCreateChild: (node: PresetNode) => void
onCreateGroup: (node: PresetNode) => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onMove: (node: PresetNode, direction: 1 | -1) => void
onRename: (node: PresetNode, name: string) => void | Promise<void>
onSelect: (node: PresetNode) => void
onSet: (node: PresetNode, path: string[], value: unknown) => void
onUnset: (node: PresetNode, path: string[]) => void
}
interface TreeItem {
node: PresetNode
children: TreeItem[]
siblingIndex: number
siblingCount: number
}
interface VisibleTreeItem {
item: TreeItem
inheritedParams: JsonObject
parentId: string | null
}
function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function valuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
function getParamPath(value: JsonObject, path: string[]) {
let current: unknown = value
for (const segment of path) {
if (!isPlainObject(current) || !(segment in current)) return undefined
current = current[segment]
}
return current
}
function formatParamValue(path: string[], value: unknown) {
if (path.join('.') === 'simulation.initialCondition' && isPlainObject(value)) {
const cells = Array.isArray(value.cells) ? value.cells.length : 0
return `${cells} live cells`
}
if (Array.isArray(value)) return `${value.length} items`
if (isPlainObject(value)) return 'object'
return String(value)
}
function mergeParamObjects(base: JsonObject, patch: JsonObject): JsonObject {
const merged: JsonObject = { ...base }
for (const [key, value] of Object.entries(patch)) {
const current = merged[key]
merged[key] = isPlainObject(current) && isPlainObject(value) ? mergeParamObjects(current, value) : value
}
return merged
}
function compareNodesByOrder(left: PresetNode, right: PresetNode) {
return left.sort_order - right.sort_order || left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug)
}
function buildTree(nodes: PresetNode[]): TreeItem[] {
const byParent = new Map<string | null, PresetNode[]>()
for (const node of nodes) {
const siblings = byParent.get(node.parent_id) ?? []
siblings.push(node)
byParent.set(node.parent_id, siblings)
}
function build(parentId: string | null): TreeItem[] {
const siblings = (byParent.get(parentId) ?? []).sort(compareNodesByOrder)
return siblings.map((node, index) => ({
node,
children: build(node.id),
siblingIndex: index,
siblingCount: siblings.length
}))
}
return build(null)
}
function flattenVisibleTree(
items: TreeItem[],
expandedNodeIds: Set<string>,
inheritedParams: JsonObject = {},
parentId: string | null = null
): VisibleTreeItem[] {
const visible: VisibleTreeItem[] = []
for (const item of items) {
visible.push({ item, inheritedParams, parentId })
if (expandedNodeIds.has(item.node.id)) {
const resolvedParams = mergeParamObjects(inheritedParams, item.node.params)
visible.push(...flattenVisibleTree(item.children, expandedNodeIds, resolvedParams, item.node.id))
}
}
return visible
}
export function PresetNodeTree({
assignedNodeId,
engines = [],
nodes,
selectedNodeId,
usedNodeIds,
onCreateChild,
onCreateGroup,
onDelete,
onMove,
onRename,
onSelect,
onSet,
onUnset
}: PresetNodeTreeProps) {
const tree = React.useMemo(() => buildTree(nodes), [nodes])
const [expandedNodeIds, setExpandedNodeIds] = React.useState<Set<string>>(
() => new Set(nodes.map((node) => node.id))
)
const [editingNodeId, setEditingNodeId] = React.useState('')
const [openMenuNodeId, setOpenMenuNodeId] = React.useState('')
const visibleItems = React.useMemo(
() => flattenVisibleTree(tree, expandedNodeIds),
[expandedNodeIds, tree]
)
React.useEffect(() => {
setExpandedNodeIds((current) => {
const next = new Set([...current].filter((id) => nodes.some((node) => node.id === id)))
for (const node of nodes) {
if (!current.has(node.id)) next.add(node.id)
}
return next
})
}, [nodes])
React.useEffect(() => {
if (!openMenuNodeId) return
function closeMenu(event: PointerEvent) {
const target = event.target
if (!(target instanceof Element)) return
if (target.closest('.tree-node-action-menu') || target.closest('.node-action-trigger')) return
setOpenMenuNodeId('')
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key === 'Escape') setOpenMenuNodeId('')
}
document.addEventListener('pointerdown', closeMenu)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('pointerdown', closeMenu)
document.removeEventListener('keydown', closeOnEscape)
}
}, [openMenuNodeId])
function toggleNode(nodeId: string, open?: boolean) {
setExpandedNodeIds((current) => {
const next = new Set(current)
const shouldOpen = open ?? !next.has(nodeId)
if (shouldOpen) next.add(nodeId)
else next.delete(nodeId)
return next
})
}
function handleTreeKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(event.key)) return
const selectedIndex = visibleItems.findIndex(({ item }) => item.node.id === selectedNodeId)
const currentIndex = selectedIndex === -1 ? 0 : selectedIndex
const current = visibleItems[currentIndex]
if (!current) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
const offset = event.key === 'ArrowDown' ? 1 : -1
const next = visibleItems[Math.min(Math.max(currentIndex + offset, 0), visibleItems.length - 1)]
if (next) onSelect(next.item.node)
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
if (current.item.children.length && !expandedNodeIds.has(current.item.node.id)) {
toggleNode(current.item.node.id, true)
} else if (current.item.children[0]) {
onSelect(current.item.children[0].node)
}
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
if (expandedNodeIds.has(current.item.node.id) && current.item.children.length) {
toggleNode(current.item.node.id, false)
} else if (current.parentId) {
const parent = nodes.find((node) => node.id === current.parentId)
if (parent) onSelect(parent)
}
return
}
event.preventDefault()
setEditingNodeId(current.item.node.id)
}
if (tree.length === 0) {
return <p className="empty">No nodes loaded.</p>
}
return (
<div className="preset-node-tree" role="tree" tabIndex={0} onKeyDown={handleTreeKeyDown}>
{tree.map((node) => (
<TreeNodeView
key={node.node.id}
item={node}
assignedNodeId={assignedNodeId}
engines={engines}
inheritedParams={{}}
selectedNodeId={selectedNodeId}
usedNodeIds={usedNodeIds}
expandedNodeIds={expandedNodeIds}
editingNodeId={editingNodeId}
openMenuNodeId={openMenuNodeId}
onCreateChild={onCreateChild}
onCreateGroup={onCreateGroup}
onDelete={onDelete}
onEdit={setEditingNodeId}
onMenuToggle={(nodeId) => setOpenMenuNodeId((current) => (current === nodeId ? '' : nodeId))}
onMove={onMove}
onRename={onRename}
onSelect={onSelect}
onSet={onSet}
onToggle={toggleNode}
onUnset={onUnset}
/>
))}
</div>
)
}
function TreeNodeView({
item,
assignedNodeId,
engines,
inheritedParams,
selectedNodeId,
usedNodeIds,
expandedNodeIds,
editingNodeId,
openMenuNodeId,
onCreateChild,
onCreateGroup,
onDelete,
onEdit,
onMenuToggle,
onMove,
onRename,
onSelect,
onSet,
onToggle,
onUnset
}: {
item: TreeItem
assignedNodeId?: string
engines: CaEngine[]
inheritedParams: JsonObject
selectedNodeId?: string
usedNodeIds: Set<string>
expandedNodeIds: Set<string>
editingNodeId: string
openMenuNodeId: string
onCreateChild: (node: PresetNode) => void
onCreateGroup: (node: PresetNode) => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onEdit: (nodeId: string) => void
onMenuToggle: (nodeId: string) => void
onMove: (node: PresetNode, direction: 1 | -1) => void
onRename: (node: PresetNode, name: string) => void | Promise<void>
onSelect: (node: PresetNode) => void
onSet: (node: PresetNode, path: string[], value: unknown) => void
onToggle: (nodeId: string, open?: boolean) => void
onUnset: (node: PresetNode, path: string[]) => void
}) {
const open = expandedNodeIds.has(item.node.id)
const menuOpen = openMenuNodeId === item.node.id
const selected = item.node.id === selectedNodeId
const assigned = item.node.id === assignedNodeId
const used = usedNodeIds.has(item.node.id)
const usedDescendantCount = React.useMemo(() => {
function countUsedDescendants(children: TreeItem[]): number {
return children.reduce(
(total, child) =>
total + (usedNodeIds.has(child.node.id) ? 1 : 0) + countUsedDescendants(child.children),
0
)
}
return countUsedDescendants(item.children)
}, [item.children, usedNodeIds])
const resolvedParams = mergeParamObjects(inheritedParams, item.node.params)
const kindLabel = item.node.kind === 'preset_root' ? 'root' : item.node.kind === 'chapter' ? 'group' : item.node.kind
const usageLabel = assigned
? `${kindLabel} · assigned`
: used
? `${kindLabel} · used by slides`
: usedDescendantCount > 0
? `${kindLabel} · contains ${usedDescendantCount} used preset${usedDescendantCount === 1 ? '' : 's'}`
: `${kindLabel} · unused`
async function deleteFromMenu() {
onMenuToggle(item.node.id)
if (!window.confirm(`Delete preset "${item.node.name}"? This cannot be undone.`)) return
await onDelete(item.node)
}
return (
<div className="tree-item">
<div className={`tree-node${selected ? ' active' : ''}${assigned ? ' assigned' : ''}${used || usedDescendantCount > 0 ? ' used' : ' unused'}`}>
<button
aria-label={item.children.length ? `${open ? 'Collapse' : 'Expand'} ${item.node.name}` : `${item.node.name} has no children`}
className="tree-node-arrow"
disabled={!item.children.length}
type="button"
onClick={() => onToggle(item.node.id)}
>
{item.children.length ? (open ? '▾' : '▸') : '•'}
</button>
<button
aria-current={selected ? 'true' : undefined}
className="tree-node-main"
role="treeitem"
type="button"
onClick={() => onSelect(item.node)}
>
<strong>{item.node.name}</strong>
<small>{usageLabel}</small>
</button>
<div className="tree-node-action-shell">
<button
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label={`Actions for ${item.node.name}`}
className="icon-button tree-node-edit node-action-trigger"
title="Preset actions"
type="button"
onClick={(event) => {
event.stopPropagation()
onMenuToggle(item.node.id)
}}
>
</button>
{menuOpen ? (
<div className="tree-node-action-menu" role="menu">
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onEdit(item.node.id)
}}>
Edit
</button>
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onCreateChild(item.node)
}}>
Create child preset
</button>
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onCreateGroup(item.node)
}}>
Create child group
</button>
<button disabled={item.siblingIndex === 0} role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onMove(item.node, -1)
}}>
Move up
</button>
<button disabled={item.siblingIndex >= item.siblingCount - 1} role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onMove(item.node, 1)
}}>
Move down
</button>
<button className="danger" role="menuitem" type="button" onClick={() => void deleteFromMenu()}>
Delete
</button>
</div>
) : null}
</div>
</div>
{editingNodeId === item.node.id ? (
<NodePropertiesModal
engines={engines}
inheritedParams={inheritedParams}
node={item.node}
resolvedParams={resolvedParams}
onClose={() => onEdit('')}
onDelete={onDelete}
onRename={onRename}
onSet={onSet}
onUnset={onUnset}
/>
) : null}
{open && item.children.length ? (
<div className="tree-children">
{item.children.map((child) => (
<TreeNodeView
assignedNodeId={assignedNodeId}
engines={engines}
inheritedParams={resolvedParams}
item={child}
key={child.node.id}
usedNodeIds={usedNodeIds}
expandedNodeIds={expandedNodeIds}
editingNodeId={editingNodeId}
openMenuNodeId={openMenuNodeId}
onCreateChild={onCreateChild}
onCreateGroup={onCreateGroup}
onDelete={onDelete}
onEdit={onEdit}
onMenuToggle={onMenuToggle}
onMove={onMove}
onRename={onRename}
onSelect={onSelect}
onSet={onSet}
onToggle={onToggle}
onUnset={onUnset}
selectedNodeId={selectedNodeId}
/>
))}
</div>
) : null}
</div>
)
}
function collectResolvedParams(value: unknown, prefix: string[] = []): Array<{ path: string[]; value: unknown }> {
if (!isPlainObject(value)) return []
const entries: Array<{ path: string[]; value: unknown }> = []
for (const [key, child] of Object.entries(value)) {
const path = [...prefix, key]
if (path.join('.') === 'simulation.initialCondition' || !isPlainObject(child)) {
entries.push({ path, value: child })
} else {
entries.push(...collectResolvedParams(child, path))
}
}
return entries
}
function isGenericEditableProperty(path: string[]) {
const joinedPath = path.join('.')
return (
joinedPath !== 'simulation.initialCondition' &&
joinedPath !== 'simulation.engineId' &&
joinedPath !== 'simulation.neighborhoodId' &&
!joinedPath.startsWith('caClass.')
)
}
function engineParamPath(key: string) {
if (key === 'rendererId') return ['renderer', 'id']
if (key === 'ruleId') return ['simulation', 'ruleId']
if (key.startsWith('grid.')) return ['simulation', ...key.split('.')]
if (key.includes('.')) return key.split('.')
return ['simulation', key]
}
function engineSettingKeys(engine: CaEngine | undefined) {
return engine ? Object.keys(engine.params_schema) : []
}
function isEngineSettingPath(path: string[], engine: CaEngine | undefined) {
const joinedPath = path.join('.')
return engineSettingKeys(engine).some((key) => engineParamPath(key).join('.') === joinedPath)
}
function parsePropertyDraft(value: string) {
const trimmed = value.trim()
if (trimmed === '') return ''
try {
return JSON.parse(trimmed) as unknown
} catch {
return value
}
}
function formatPropertyDraft(value: unknown) {
if (typeof value === 'string') return value
return JSON.stringify(value, null, 2)
}
function NodePropertiesModal({
engines,
inheritedParams,
node,
resolvedParams,
onClose,
onDelete,
onRename,
onSet,
onUnset
}: {
engines: CaEngine[]
inheritedParams: JsonObject
node: PresetNode
resolvedParams: JsonObject
onClose: () => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onRename: (node: PresetNode, name: string) => void | Promise<void>
onSet: (node: PresetNode, path: string[], value: unknown) => void
onUnset: (node: PresetNode, path: string[]) => void
}) {
const [nameDraft, setNameDraft] = React.useState(node.name)
const resolvedEngineId = getParamPath(resolvedParams, ['simulation', 'engineId'])
const caClass = getCaClassFromParams(resolvedParams)
const resolvedEngine =
typeof resolvedEngineId === 'string'
? engines.find((engine) => engine.engine_kind === resolvedEngineId)
: undefined
const resolvedEngineCompatible = resolvedEngine ? supportsCaClass(resolvedEngine, caClass) : false
const activeEngine = resolvedEngineCompatible ? resolvedEngine : undefined
const properties = collectResolvedParams(resolvedParams).filter(
(property) => isGenericEditableProperty(property.path) && !isEngineSettingPath(property.path, activeEngine)
)
React.useEffect(() => {
setNameDraft(node.name)
}, [node.name])
async function saveName() {
const name = nameDraft.trim()
if (!name) {
setNameDraft(node.name)
return
}
if (name !== node.name) await onRename(node, name)
}
async function deleteNode() {
if (!window.confirm(`Delete preset "${node.name}"? This cannot be undone.`)) return
const deleted = await onDelete(node)
if (deleted === false) return
onClose()
}
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<section aria-modal="true" className="property-modal" role="dialog" onMouseDown={(event) => event.stopPropagation()}>
<header className="property-modal-header">
<div>
<p className="eyebrow">Preset Editor</p>
<h2>Edit preset</h2>
</div>
<button type="button" onClick={onClose}>
Close
</button>
</header>
<div className="property-modal-list">
<section className="preset-modal-stage preset-modal-identity">
<div className="preset-modal-stage-heading">
<span>i</span>
<div>
<h3>Preset identity</h3>
<p>Edit CA space, engine, and engine settings in the inspector.</p>
</div>
</div>
<label>
Preset name
<input
autoFocus
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void saveName()
}
}}
/>
</label>
<button disabled={!nameDraft.trim() || nameDraft.trim() === node.name} type="button" onClick={() => void saveName()}>
Save name
</button>
</section>
{properties.map((property) => {
const localValue = getParamPath(node.params, property.path)
const inheritedValue = getParamPath(inheritedParams, property.path)
const isLocal = localValue !== undefined && !valuesEqual(localValue, inheritedValue)
return (
<PropertyEditorRow
inheritedValue={inheritedValue}
isLocal={isLocal}
key={property.path.join('.')}
node={node}
path={property.path}
value={property.value}
onSet={onSet}
onUnset={onUnset}
/>
)
})}
{properties.length === 0 ? <p className="empty">No properties are resolved for this node.</p> : null}
<footer className="preset-modal-danger">
<div>
<strong>Delete preset</strong>
<small>Slides referencing this preset must be reassigned first.</small>
</div>
<button className="danger" type="button" onClick={() => void deleteNode()}>
Delete
</button>
</footer>
</div>
</section>
</div>
)
}
function PropertyEditorRow({
inheritedValue,
isLocal,
node,
path,
value,
onSet,
onUnset
}: {
inheritedValue: unknown
isLocal: boolean
node: PresetNode
path: string[]
value: unknown
onSet: (node: PresetNode, path: string[], value: unknown) => void
onUnset: (node: PresetNode, path: string[]) => void
}) {
const [draft, setDraft] = React.useState(formatPropertyDraft(value))
React.useEffect(() => {
setDraft(formatPropertyDraft(value))
}, [value])
return (
<div className="property-row">
<div className="property-row-meta">
<strong>{path.join('.')}</strong>
<span className={isLocal ? 'local' : 'inherited'}>{isLocal ? 'local override' : 'inherited'}</span>
{!isLocal && inheritedValue !== undefined ? <small>from parent: {formatParamValue(path, inheritedValue)}</small> : null}
</div>
<textarea value={draft} onChange={(event) => setDraft(event.target.value)} />
<div className="property-row-actions">
<button type="button" onClick={() => onSet(node, path, parsePropertyDraft(draft))}>
Save
</button>
<button className="danger" disabled={!isLocal} type="button" onClick={() => onUnset(node, path)}>
🗑
</button>
</div>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import React from 'react'
import type { ResolvedScene } from './types.js'
interface SlideListProps {
activeSceneId: string
canDeleteSlide: boolean
sceneCount: number
scenes: ResolvedScene[]
onBack: () => void
onDeleteSlide: () => void
onEditScene: (scene: ResolvedScene) => void
onReorderSlides: (scenes: ResolvedScene[]) => void
onSaveNewSlide: () => void
onSelectScene: (scene: ResolvedScene) => void
}
export function SlideList({
activeSceneId,
canDeleteSlide,
sceneCount,
scenes,
onBack,
onDeleteSlide,
onEditScene,
onReorderSlides,
onSaveNewSlide,
onSelectScene
}: SlideListProps) {
const [draggedSceneId, setDraggedSceneId] = React.useState('')
const [dropTarget, setDropTarget] = React.useState<{ sceneId: string; position: 'before' | 'after' } | null>(null)
function captionPreview(scene: ResolvedScene) {
const caption = typeof scene.scene.params.caption === 'string' ? scene.scene.params.caption : ''
return caption.split(/\r?\n/, 1)[0].trim() || 'No caption'
}
function reorderScenes(targetSceneId: string, position: 'before' | 'after') {
if (!draggedSceneId || draggedSceneId === targetSceneId) return
const draggedScene = scenes.find((scene) => scene.scene.id === draggedSceneId)
if (!draggedScene) return
const withoutDragged = scenes.filter((scene) => scene.scene.id !== draggedSceneId)
const targetIndex = withoutDragged.findIndex((scene) => scene.scene.id === targetSceneId)
if (targetIndex === -1) return
const insertIndex = position === 'after' ? targetIndex + 1 : targetIndex
const reordered = [...withoutDragged]
reordered.splice(insertIndex, 0, draggedScene)
onReorderSlides(reordered)
}
return (
<aside className="panel slide-panel">
<div className="drawer-navigation">
<button type="button" onClick={onBack}>
Back
</button>
</div>
<div className="scene-list-header">
<p className="eyebrow">Slides</p>
<span>{sceneCount}</span>
</div>
{scenes.length ? (
scenes.map((scene) => (
<div
key={scene.scene.id}
className={`scene-item${scene.scene.id === activeSceneId ? ' active' : ''}${scene.scene.id === draggedSceneId ? ' dragging' : ''}${dropTarget?.sceneId === scene.scene.id ? ` drop-${dropTarget.position}` : ''}`}
draggable
onDragEnd={() => {
setDraggedSceneId('')
setDropTarget(null)
}}
onDragOver={(event) => {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
const position = event.clientY > rect.top + rect.height / 2 ? 'after' : 'before'
setDropTarget({ sceneId: scene.scene.id, position })
}}
onDragStart={(event) => {
setDraggedSceneId(scene.scene.id)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', scene.scene.id)
}}
onDrop={(event) => {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
const position = event.clientY > rect.top + rect.height / 2 ? 'after' : 'before'
reorderScenes(scene.scene.id, position)
setDraggedSceneId('')
setDropTarget(null)
}}
>
<button className="scene-item-main" type="button" onClick={() => onSelectScene(scene)}>
<span>{scene.scene.order_index}</span>
<strong>{scene.scene.title}</strong>
<small className="scene-caption-preview">{captionPreview(scene)}</small>
</button>
<button
aria-label={`Edit ${scene.scene.title} metadata`}
className="scene-edit-button icon-button"
type="button"
onClick={() => onEditScene(scene)}
>
</button>
</div>
))
) : (
<p className="empty">No slides yet. Choose a CA asset to create the first one.</p>
)}
<button className="primary full-width" type="button" onClick={onSaveNewSlide}>
New slide
</button>
<button className="danger full-width" disabled={!canDeleteSlide} type="button" onClick={onDeleteSlide}>
Delete selected slide
</button>
</aside>
)
}
+224
View File
@@ -0,0 +1,224 @@
import {
assertSameCellsShape,
cloneCells,
resolveBoundaryCondition,
resolveBoundaryIndex,
seededRandom
} from './caRuntime.js'
import type { CaClass, Cells, JsonObject, SceneParams } from './types.js'
export interface CaEngineRuntime {
id: string
label: string
supportedClasses?: CaClass[]
defaultParams?: JsonObject
step: (cells: Cells, settings: SceneParams) => Cells
}
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) {
throw new Error(`CA engine runtime already registered: ${engine.id}`)
}
engineRuntimes.set(engine.id, engine)
}
export function listCaEngineRuntimes() {
return [...engineRuntimes.values()]
}
export function stepCaCells(cells: Cells, settings: SceneParams) {
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)
assertSameCellsShape(cells, next, `CA engine output (${engine?.id ?? 'fallback'})`)
return next
}
registerCaEngineRuntime(
{
id: 'game-of-life-2d',
label: 'Game of Life 2D',
supportedClasses: [{ dimensions: 2, states: 2 }],
defaultParams: { ruleId: 'B3/S23', rendererId: '2d-canvas' },
step: stepOuterTotalistic2d
},
{ replace: true }
)
registerCaEngineRuntime(
{
id: 'outer-totalistic-2d',
label: 'Outer-totalistic 2D',
supportedClasses: [{ dimensions: 2, states: 2 }],
defaultParams: { ruleId: 'B3/S23', rendererId: '2d-canvas' },
step: stepOuterTotalistic2d
},
{ replace: true }
)
registerCaEngineRuntime(
{
id: 'elementary-1d',
label: 'Elementary 1D',
supportedClasses: [{ dimensions: 1, states: 2 }],
defaultParams: { ruleId: 'Rule 110', rendererId: 'elementary-1d' },
step: stepElementary1d
},
{ replace: true }
)
registerCaEngineRuntime(
{
id: 'wildfire-2d',
label: 'Wildfire 2D',
supportedClasses: [{ dimensions: 2, states: 2 }],
defaultParams: { ruleId: 'wildfire-binary', rendererId: 'wildfire-2d', spreadProbability: 1 },
step: stepWildfire2d
},
{ replace: true }
)
registerCaEngineRuntime(
{
id: 'generic-voxel-ca',
label: 'Generic voxel CA',
step: stepNoop
},
{ replace: true }
)
registerCaEngineRuntime(
{
id: 'noop',
label: 'No evolution',
step: stepNoop
},
{ replace: true }
)
@@ -0,0 +1,93 @@
import { numericGridSize, seededRandom } from './caRuntime.js'
import type { CaClass, JsonObject, SceneParams } from './types.js'
export interface InitialConditionResult {
type: 'cells'
cells: number[][]
generator: {
kind: string
} & JsonObject
}
export interface InitialConditionRuntimeInput {
settings: SceneParams
caClass: CaClass
params: JsonObject
}
export interface InitialConditionRuntime {
id: string
label: string
generate: (input: InitialConditionRuntimeInput) => InitialConditionResult
}
const initialConditionRuntimes = new Map<string, InitialConditionRuntime>()
function clampDensity(value: unknown) {
return Math.max(0, Math.min(1, typeof value === 'number' ? value : 0.28))
}
function randomSoup({ settings, caClass, params }: InitialConditionRuntimeInput): InitialConditionResult {
const density = clampDensity(params.density)
const seed = typeof params.seed === 'string' ? params.seed : 'studio-seed'
const random = seededRandom(seed)
const [width, height, depth] = numericGridSize(settings.simulation?.grid, caClass.dimensions)
const cells: number[][] = []
for (let z = 0; z < depth; z += 1) {
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (random() < density) cells.push([x, y, z])
}
}
}
return {
type: 'cells',
cells,
generator: {
kind: 'random-soup',
density,
seed
}
}
}
export function registerInitialConditionRuntime(
runtime: InitialConditionRuntime,
options: { replace?: boolean } = {}
) {
if (!runtime.id.trim()) throw new Error('Initial condition runtime id is required')
if (initialConditionRuntimes.has(runtime.id) && !options.replace) {
throw new Error(`Initial condition runtime already registered: ${runtime.id}`)
}
initialConditionRuntimes.set(runtime.id, runtime)
}
export function listInitialConditionRuntimes() {
return [...initialConditionRuntimes.values()]
}
export function getInitialConditionRuntime(runtimeId: string) {
return initialConditionRuntimes.get(runtimeId) ?? null
}
export function generateInitialCondition(runtimeId: string, input: InitialConditionRuntimeInput) {
const runtime = getInitialConditionRuntime(runtimeId)
if (!runtime) throw new Error(`Initial condition runtime unavailable: ${runtimeId}`)
const result = runtime.generate(input)
if (result.type !== 'cells' || !Array.isArray(result.cells)) {
throw new Error(`Initial condition runtime returned an invalid result: ${runtimeId}`)
}
return result
}
registerInitialConditionRuntime(
{
id: 'random-soup',
label: 'Random soup',
generate: randomSoup
},
{ replace: true }
)
+128
View File
@@ -0,0 +1,128 @@
import type { BoundaryCondition, CaClass, Cells, JsonObject, SceneParams } from './types.js'
export const DEFAULT_BOUNDARY_CONDITION: BoundaryCondition = 'wrap'
export const DEFAULT_GRID_SIZE = 24
export function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export function statesEqual(left: CaClass['states'] | undefined, right: CaClass['states'] | undefined) {
if (left === undefined || right === undefined) return false
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right)) return false
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort())
}
return left === right
}
export function supportsCaClass(runtime: { supported_classes?: CaClass[]; supportedClasses?: CaClass[] }, caClass: CaClass | undefined) {
if (!caClass) return false
const supportedClasses = runtime.supported_classes ?? runtime.supportedClasses ?? []
return supportedClasses.some(
(supportedClass) =>
supportedClass.dimensions === caClass.dimensions &&
statesEqual(supportedClass.states, caClass.states) &&
neighborhoodsCompatible(supportedClass, caClass)
)
}
function neighborhoodsCompatible(supportedClass: CaClass, caClass: CaClass) {
if (!supportedClass.neighborhoodId || !caClass.neighborhoodId) return true
return supportedClass.neighborhoodId === caClass.neighborhoodId
}
export function getCaClassFromParams(params: JsonObject | undefined): CaClass | undefined {
const caClass = params?.caClass
if (!isPlainObject(caClass)) return undefined
const dimensions = typeof caClass.dimensions === 'number' ? caClass.dimensions : undefined
const states = caClass.states
if (!dimensions || (typeof states !== 'number' && !Array.isArray(states))) return undefined
return {
neighborhoodId: typeof caClass.neighborhoodId === 'string' ? caClass.neighborhoodId : undefined,
dimensions,
states
}
}
export function numericGridSize(value: unknown, dimensions: number, fallbackSize = DEFAULT_GRID_SIZE) {
const grid = isPlainObject(value) ? value : {}
const size = Array.isArray(grid.size) ? grid.size : []
const x = typeof size[0] === 'number' ? size[0] : fallbackSize
const y = dimensions >= 2 && typeof size[1] === 'number' ? size[1] : 1
const z = dimensions >= 3 && typeof size[2] === 'number' ? size[2] : 1
return [x, y, z] as const
}
export function seededRandom(seed: string) {
let state = 2166136261
for (let index = 0; index < seed.length; index += 1) {
state ^= seed.charCodeAt(index)
state = Math.imul(state, 16777619)
}
return () => {
state += 0x6d2b79f5
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export function isBoundaryCondition(value: unknown): value is BoundaryCondition {
return value === 'wrap' || value === 'mirror' || value === 'fixed'
}
export function getBoundaryCondition(params: SceneParams | undefined): BoundaryCondition {
return resolveBoundaryCondition(params?.simulation?.grid)
}
export function resolveBoundaryCondition(grid: JsonObject | undefined): BoundaryCondition {
if (isBoundaryCondition(grid?.boundary)) return grid.boundary
if (grid?.wrap === false) return 'fixed'
return DEFAULT_BOUNDARY_CONDITION
}
export function wrapIndex(index: number, size: number) {
return ((index % size) + size) % size
}
export function mirrorIndex(index: number, size: number) {
if (size <= 1) return 0
const period = (size - 1) * 2
const wrapped = wrapIndex(index, period)
return wrapped <= size - 1 ? wrapped : period - wrapped
}
export function resolveBoundaryIndex(index: number, size: number, boundary: BoundaryCondition) {
if (size <= 0) return null
if (boundary === 'wrap') return wrapIndex(index, size)
if (boundary === 'mirror') return mirrorIndex(index, size)
return index >= 0 && index < size ? index : null
}
export function cloneCells(cells: Cells): Cells {
return cells.map((row) => [...row])
}
export function assertCellsShape(cells: Cells, label = 'cells') {
const width = cells[0]?.length ?? 0
for (const row of cells) {
if (row.length !== width) throw new Error(`${label} must be rectangular`)
for (const value of row) {
if (typeof value !== 'boolean') throw new Error(`${label} must contain boolean cell states`)
}
}
}
export function assertSameCellsShape(reference: Cells, next: Cells, label = 'engine output') {
assertCellsShape(reference, 'input cells')
assertCellsShape(next, label)
if (next.length !== reference.length || (next[0]?.length ?? 0) !== (reference[0]?.length ?? 0)) {
throw new Error(`${label} must preserve the input grid dimensions`)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
export type JsonObject = Record<string, unknown>
export type Cells = boolean[][]
export type BoundaryCondition = 'wrap' | 'mirror' | 'fixed'
export interface Deck {
id: string
slug: string
title: string
description: string | null
params: JsonObject
}
export interface PresetTree {
id: string
slug: string
name: string
node_count: number
}
export interface PresetNode {
id: string
tree_id: string
parent_id: string | null
slug: string
name: string
kind: string
description: string | null
sort_order: number
params: JsonObject
}
export interface PresetNodeUsage {
node_id: string
scene_count: number
}
export interface Scene {
id: string
deck_id: string
order_index: number
title: string
preset_tree_id: string | null
preset_node_id: string | null
apply_mode: 'reinitialize' | 'patch_existing'
requires_previous_scene: boolean
params: JsonObject
}
export interface SceneParams extends JsonObject {
caClass?: {
neighborhoodId?: string
dimensions?: number
states?: number | string[]
}
simulation?: {
initialCondition?: {
type?: string
cells?: number[][]
}
ruleId?: string
neighborhoodId?: string
engineId?: string
grid?: JsonObject
} & JsonObject
renderer?: {
id?: string
} & JsonObject
}
export interface CaClass {
neighborhoodId?: string
dimensions: number
states: number | string[]
}
export interface InitialConditionGenerator {
id: string
slug: string
name: string
description: string | null
generator_kind: string
supported_classes: CaClass[]
params_schema: JsonObject
default_params: JsonObject
}
export interface CaEngine {
id: string
slug: string
name: string
description: string | null
engine_kind: string
license: string | null
owner: string | null
ip_notice: string | null
supported_classes: CaClass[]
params_schema: JsonObject
default_params: JsonObject
}
export interface CaEnginePresetUsage {
tree_id: string
tree_name: string
node_id: string
node_name: string
node_kind: string
}
export interface ResolvedScene {
scene: Scene
ancestry: PresetNode[]
params: SceneParams
}
export interface ResolvedDeck {
deck: Deck
scenes: ResolvedScene[]
}
export interface ResolvedNode {
treeId: string
nodeId: string
ancestry: PresetNode[]
params: SceneParams
}
export interface DeckStudioParams {
presetTreeId?: string
rootNodeId?: string
rendererId?: string
neighborhoodId?: string
ruleId?: string
}
@@ -0,0 +1,74 @@
import React from 'react'
import type { SceneParams } from '../types.js'
import { createVoxelRendererCore } from './voxelRendererCore.js'
import type { VoxelCell, VoxelRendererOptions } from './voxelRendererCore.js'
function numberFromGrid(settings: SceneParams) {
const size = settings.simulation?.grid?.size
if (!Array.isArray(size)) return 16
const first = size[0]
return typeof first === 'number' ? Math.min(30, Math.max(8, Math.round(first))) : 16
}
function voxelRuleId(settings: SceneParams) {
const ruleId = settings.simulation?.ruleId
return typeof ruleId === 'string' && ruleId.endsWith('-3d') ? ruleId : 'life-3d'
}
function rendererOptions(settings: SceneParams): VoxelRendererOptions {
const renderer = settings.renderer ?? {}
return {
accentColor: typeof renderer.accentColor === 'string' ? renderer.accentColor : '#f0c94a',
cellGap: typeof renderer.cellGap === 'number' ? renderer.cellGap : 0.14,
primaryColor: typeof renderer.primaryColor === 'string' ? renderer.primaryColor : '#70f45f',
showBounds: typeof renderer.showBounds === 'boolean' ? renderer.showBounds : true
}
}
function voxelCells(settings: SceneParams): VoxelCell[] {
const cells = settings.simulation?.initialCondition?.cells
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 }]
})
}
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 size = numberFromGrid(settings)
const ruleId = voxelRuleId(settings)
const options = React.useMemo(() => rendererOptions(settings), [settings])
React.useEffect(() => {
const mount = mountRef.current
if (!mount) return undefined
const renderer = createVoxelRendererCore(mount, options)
rendererRef.current = renderer
renderer.update({ cells, options, ruleId, size })
return () => {
renderer.dispose()
rendererRef.current = null
}
// The core owns the WebGL lifecycle; param changes update through the effect below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
React.useEffect(() => {
rendererRef.current?.update({ cells, options, ruleId, size })
}, [cells, options, ruleId, size])
return (
<div className="board-wrap voxel-renderer-wrap">
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
<div className="studio-voxel-viewport" ref={mountRef} />
{cells.length === 0 ? <div className="voxel-empty-hint">No voxel initial condition on this node</div> : null}
</div>
)
}
@@ -0,0 +1,268 @@
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
const WORLD_SIZE = 16
const CAMERA_DISTANCE = 28
const CAMERA_HEIGHT = 20
export interface VoxelCell {
x: number
y: number
z: number
state?: number
}
export interface VoxelRendererOptions {
accentColor: string
cellGap: number
primaryColor: string
showBounds: boolean
}
export interface VoxelRendererCore {
dispose: () => void
render: () => void
update: (input: { cells: VoxelCell[]; options: VoxelRendererOptions; ruleId: string; size: number }) => void
}
interface SceneBundle {
animationFrame: number
bounds: THREE.LineSegments
camera: THREE.PerspectiveCamera
controls: OrbitControls
grid: THREE.GridHelper
mesh: THREE.InstancedMesh | null
mountNode: HTMLDivElement
primaryColor: THREE.Color
accentColor: THREE.Color
renderer: THREE.WebGLRenderer
resizeObserver: ResizeObserver
scene: THREE.Scene
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function disposeMaterial(material: THREE.Material | THREE.Material[]) {
const materials = Array.isArray(material) ? material : [material]
for (const item of materials) {
item.dispose()
}
}
function disposeMesh(mesh: THREE.InstancedMesh | null) {
if (!mesh) return
mesh.geometry.dispose()
disposeMaterial(mesh.material)
}
function createVoxelMaterial(primaryColor: THREE.Color) {
return new THREE.MeshStandardMaterial({
color: primaryColor,
emissive: primaryColor.clone().multiplyScalar(0.36),
emissiveIntensity: 0.26,
metalness: 0.04,
roughness: 0.28,
vertexColors: true
})
}
function createBounds(options: VoxelRendererOptions) {
const geometry = new THREE.EdgesGeometry(new THREE.BoxGeometry(WORLD_SIZE, WORLD_SIZE, WORLD_SIZE))
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(options.primaryColor).lerp(new THREE.Color('#ffffff'), 0.1)
})
const bounds = new THREE.LineSegments(geometry, material)
bounds.visible = options.showBounds
return bounds
}
function createGrid(options: VoxelRendererOptions) {
const grid = new THREE.GridHelper(
WORLD_SIZE,
12,
new THREE.Color(options.accentColor).lerp(new THREE.Color('#ffffff'), 0.05),
new THREE.Color(options.primaryColor).lerp(new THREE.Color('#000000'), 0.4)
)
grid.position.y = -WORLD_SIZE / 2
const material = grid.material as THREE.Material
material.opacity = 0.28
material.transparent = true
return grid
}
function updateSceneChrome(bundle: SceneBundle, options: VoxelRendererOptions) {
bundle.primaryColor = new THREE.Color(options.primaryColor)
bundle.accentColor = new THREE.Color(options.accentColor)
bundle.bounds.visible = options.showBounds
;(bundle.bounds.material as THREE.LineBasicMaterial).color = bundle.primaryColor.clone().lerp(new THREE.Color('#ffffff'), 0.1)
}
function createVoxelMesh(size: number, options: VoxelRendererOptions) {
const spacing = WORLD_SIZE / size
const edge = spacing * (1 - clamp(options.cellGap, 0.02, 0.45))
const geometry = new THREE.BoxGeometry(edge, edge, edge)
const material = createVoxelMaterial(new THREE.Color(options.primaryColor))
const mesh = new THREE.InstancedMesh(geometry, material, Math.max(1, size ** 3))
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
mesh.castShadow = true
mesh.receiveShadow = true
return mesh
}
function colorForCell(bundle: SceneBundle, cell: VoxelCell, size: number, ruleId: string, 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)
target.copy(bundle.primaryColor).lerp(bundle.accentColor, clamp(heightMix * 0.42 + stateMix * 0.5, 0, 1))
if (ruleId === 'lattice-gas-3d' || ruleId === 'snake-3d') {
target.lerp(new THREE.Color('#ffffff'), clamp(stateMix * 0.22 + depthMix * 0.08, 0, 0.34))
target.multiplyScalar(1 + stateMix * 0.16)
return
}
if (ruleId === 'generations-3d') {
target.lerp(new THREE.Color('#94a3b8'), stateMix * 0.22)
}
target.lerp(new THREE.Color('#ffffff'), clamp(depthMix * 0.18, 0, 0.24))
target.offsetHSL(0, 0, 0.05 + depthMix * 0.1)
}
function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number, ruleId: string, options: VoxelRendererOptions) {
disposeMesh(bundle.mesh)
bundle.mesh = createVoxelMesh(size, options)
bundle.scene.add(bundle.mesh)
const mesh = bundle.mesh
const dummy = new THREE.Object3D()
const color = new THREE.Color()
const spacing = WORLD_SIZE / size
const origin = -WORLD_SIZE / 2 + spacing / 2
let instanceIndex = 0
for (const cell of cells) {
if (instanceIndex >= mesh.count) break
if (cell.x < 0 || cell.y < 0 || cell.z < 0 || cell.x >= size || cell.y >= size || cell.z >= size) continue
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)
mesh.setMatrixAt(instanceIndex, dummy.matrix)
mesh.setColorAt(instanceIndex, color)
instanceIndex += 1
}
mesh.count = instanceIndex
mesh.instanceMatrix.needsUpdate = true
if (mesh.instanceColor) {
mesh.instanceColor.needsUpdate = true
}
}
export function createVoxelRendererCore(targetNode: HTMLDivElement, options: VoxelRendererOptions): VoxelRendererCore {
const scene = new THREE.Scene()
scene.background = new THREE.Color('#061006')
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 300)
camera.position.set(CAMERA_DISTANCE, CAMERA_HEIGHT, CAMERA_DISTANCE)
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.12
targetNode.appendChild(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.06
controls.minDistance = 12
controls.maxDistance = 60
controls.target.set(0, 0, 0)
scene.add(new THREE.AmbientLight('#f5efb7', 0.42))
scene.add(new THREE.HemisphereLight('#f5efb7', '#061006', 0.96))
const keyLight = new THREE.DirectionalLight('#fff3a8', 1.25)
keyLight.position.set(18, 28, 14)
keyLight.target.position.set(0, 0, 0)
scene.add(keyLight)
scene.add(keyLight.target)
const fillLight = new THREE.DirectionalLight(options.accentColor, 0.46)
fillLight.position.set(-24, 18, -12)
scene.add(fillLight)
const bounds = createBounds(options)
scene.add(bounds)
const grid = createGrid(options)
scene.add(grid)
const bundle: SceneBundle = {
animationFrame: 0,
bounds,
camera,
controls,
grid,
mesh: null,
mountNode: targetNode,
primaryColor: new THREE.Color(options.primaryColor),
accentColor: new THREE.Color(options.accentColor),
renderer,
resizeObserver: new ResizeObserver(() => resize()),
scene
}
function resize() {
const width = Math.max(targetNode.clientWidth, 1)
const height = Math.max(targetNode.clientHeight, 1)
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height, false)
}
function render() {
controls.update()
renderer.render(scene, camera)
}
function animate() {
render()
bundle.animationFrame = window.requestAnimationFrame(animate)
}
bundle.resizeObserver.observe(targetNode)
resize()
animate()
return {
dispose() {
window.cancelAnimationFrame(bundle.animationFrame)
bundle.resizeObserver.disconnect()
disposeMesh(bundle.mesh)
bundle.bounds.geometry.dispose()
disposeMaterial(bundle.bounds.material)
bundle.grid.geometry.dispose()
disposeMaterial(bundle.grid.material)
controls.dispose()
renderer.dispose()
if (renderer.domElement.parentNode === targetNode) {
targetNode.removeChild(renderer.domElement)
}
},
render,
update(input) {
updateSceneChrome(bundle, input.options)
updateVoxelMesh(bundle, input.cells, input.size, input.ruleId, input.options)
render()
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react()],
root: __dirname,
base: '/admin/',
resolve: {
dedupe: ['react', 'react-dom']
},
build: {
outDir: resolve(__dirname, '../public/admin'),
emptyOutDir: true
},
server: {
port: 5174,
proxy: {
'/api': 'http://localhost:3100'
}
}
})
+235
View File
@@ -0,0 +1,235 @@
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createCaEngine, createDeck, createInitialConditionGenerator, createPresetNode, createPresetTree, createScene, deleteDeck, deletePresetNode, deletePresetTree, deleteScene, getCaEngine, getDeck, getInitialConditionGenerator, getPresetNode, getScene, listCaEngines, listCaEnginePresetUsage, listDecks, listInitialConditionGenerators, listPresetNodeUsage, listPresetNodes, listPresetTrees, listScenes, resolveDeck, resolvePresetNode, resolveScene, updateCaEngine, updateDeck, updateInitialConditionGenerator, updatePresetNode, updateScene } from './caStudioRepository.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const adminPublicPath = resolve(__dirname, '../public/admin');
function asyncRoute(handler) {
return (request, response, next) => {
handler(request, response).catch(next);
};
}
function routeParam(request, key) {
const value = request.params[key];
if (typeof value !== 'string') {
throw new Error(`Missing route parameter: ${key}`);
}
return value;
}
function queryString(value) {
return typeof value === 'string' ? value : undefined;
}
function parseStates(value) {
const trimmed = value.trim();
if (/^\d+$/.test(trimmed))
return Number(trimmed);
if (trimmed.startsWith('['))
return JSON.parse(trimmed);
return trimmed.split(',').map((state) => state.trim()).filter(Boolean);
}
function caClassFromQuery(query) {
const dimensions = queryString(query.dimensions);
const states = queryString(query.states);
if (!dimensions && !states)
return undefined;
if (!dimensions || !states) {
throw new Error('Filtering initial condition generators requires dimensions and states');
}
return {
dimensions: Number(dimensions),
states: parseStates(states)
};
}
export function createCaStudioApi(db) {
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use('/admin', express.static(adminPublicPath));
app.get('/', (_request, response) => {
response.redirect('/admin');
});
app.get(/^\/(?:admin|view)(?:\/.*)?$/, (_request, response) => {
response.sendFile(resolve(adminPublicPath, 'index.html'));
});
app.post('/api/ca/preset-trees', asyncRoute(async (request, response) => {
const tree = await createPresetTree(db, request.body);
response.status(201).json({ ...tree, node_count: 0 });
}));
app.get('/api/ca/preset-trees', asyncRoute(async (_request, response) => {
response.json(await listPresetTrees(db));
}));
app.delete('/api/ca/preset-trees/:treeId', asyncRoute(async (request, response) => {
const deleted = await deletePresetTree(db, routeParam(request, 'treeId'));
response.sendStatus(deleted ? 204 : 404);
}));
app.get('/api/ca/preset-trees/:treeId/nodes', asyncRoute(async (request, response) => {
response.json(await listPresetNodes(db, routeParam(request, 'treeId')));
}));
app.post('/api/ca/preset-trees/:treeId/nodes', asyncRoute(async (request, response) => {
const node = await createPresetNode(db, {
...request.body,
treeId: routeParam(request, 'treeId')
});
response.status(201).json(node);
}));
app.get('/api/ca/preset-trees/:treeId/nodes/:nodeId/resolved', asyncRoute(async (request, response) => {
const resolved = await resolvePresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'));
if (!resolved) {
response.sendStatus(404);
return;
}
response.json(resolved);
}));
app.get('/api/ca/preset-trees/:treeId/nodes/:nodeId', asyncRoute(async (request, response) => {
const node = await getPresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'));
if (!node) {
response.sendStatus(404);
return;
}
response.json(node);
}));
app.get('/api/ca/preset-trees/:treeId/node-usage', asyncRoute(async (request, response) => {
response.json(await listPresetNodeUsage(db, routeParam(request, 'treeId')));
}));
app.patch('/api/ca/preset-trees/:treeId/nodes/:nodeId', asyncRoute(async (request, response) => {
const node = await updatePresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'), request.body);
if (!node) {
response.sendStatus(404);
return;
}
response.json(node);
}));
app.delete('/api/ca/preset-trees/:treeId/nodes/:nodeId', asyncRoute(async (request, response) => {
const deleted = await deletePresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'));
response.sendStatus(deleted ? 204 : 404);
}));
app.post('/api/ca/initial-condition-generators', asyncRoute(async (request, response) => {
response.status(201).json(await createInitialConditionGenerator(db, request.body));
}));
app.get('/api/ca/initial-condition-generators', asyncRoute(async (request, response) => {
response.json(await listInitialConditionGenerators(db, { caClass: caClassFromQuery(request.query) }));
}));
app.get('/api/ca/initial-condition-generators/:generatorId', asyncRoute(async (request, response) => {
const generator = await getInitialConditionGenerator(db, routeParam(request, 'generatorId'));
if (!generator) {
response.sendStatus(404);
return;
}
response.json(generator);
}));
app.patch('/api/ca/initial-condition-generators/:generatorId', asyncRoute(async (request, response) => {
const generator = await updateInitialConditionGenerator(db, routeParam(request, 'generatorId'), request.body);
if (!generator) {
response.sendStatus(404);
return;
}
response.json(generator);
}));
app.post('/api/ca/engines', asyncRoute(async (request, response) => {
response.status(201).json(await createCaEngine(db, request.body));
}));
app.get('/api/ca/engines', asyncRoute(async (request, response) => {
response.json(await listCaEngines(db, { caClass: caClassFromQuery(request.query) }));
}));
app.get('/api/ca/engines/:engineId/usage', asyncRoute(async (request, response) => {
const usage = await listCaEnginePresetUsage(db, routeParam(request, 'engineId'));
if (!usage) {
response.sendStatus(404);
return;
}
response.json(usage);
}));
app.get('/api/ca/engines/:engineId', asyncRoute(async (request, response) => {
const engine = await getCaEngine(db, routeParam(request, 'engineId'));
if (!engine) {
response.sendStatus(404);
return;
}
response.json(engine);
}));
app.patch('/api/ca/engines/:engineId', asyncRoute(async (request, response) => {
const engine = await updateCaEngine(db, routeParam(request, 'engineId'), request.body);
if (!engine) {
response.sendStatus(404);
return;
}
response.json(engine);
}));
app.post('/api/ca/decks', asyncRoute(async (request, response) => {
response.status(201).json(await createDeck(db, request.body));
}));
app.get('/api/ca/decks', asyncRoute(async (_request, response) => {
response.json(await listDecks(db));
}));
app.get('/api/ca/decks/:deckId', asyncRoute(async (request, response) => {
const deck = await getDeck(db, routeParam(request, 'deckId'));
if (!deck) {
response.sendStatus(404);
return;
}
response.json(deck);
}));
app.patch('/api/ca/decks/:deckId', asyncRoute(async (request, response) => {
const deck = await updateDeck(db, routeParam(request, 'deckId'), request.body);
if (!deck) {
response.sendStatus(404);
return;
}
response.json(deck);
}));
app.delete('/api/ca/decks/:deckId', asyncRoute(async (request, response) => {
const deleted = await deleteDeck(db, routeParam(request, 'deckId'));
response.sendStatus(deleted ? 204 : 404);
}));
app.get('/api/ca/decks/:deckId/resolved', asyncRoute(async (request, response) => {
const resolved = await resolveDeck(db, routeParam(request, 'deckId'));
if (!resolved) {
response.sendStatus(404);
return;
}
response.json(resolved);
}));
app.get('/api/ca/decks/:deckId/scenes', asyncRoute(async (request, response) => {
response.json(await listScenes(db, routeParam(request, 'deckId')));
}));
app.post('/api/ca/decks/:deckId/scenes', asyncRoute(async (request, response) => {
const scene = await createScene(db, {
...request.body,
deckId: routeParam(request, 'deckId')
});
response.status(201).json(scene);
}));
app.get('/api/ca/scenes/:sceneId', asyncRoute(async (request, response) => {
const scene = await getScene(db, routeParam(request, 'sceneId'));
if (!scene) {
response.sendStatus(404);
return;
}
response.json(scene);
}));
app.patch('/api/ca/scenes/:sceneId', asyncRoute(async (request, response) => {
const scene = await updateScene(db, routeParam(request, 'sceneId'), request.body);
if (!scene) {
response.sendStatus(404);
return;
}
response.json(scene);
}));
app.delete('/api/ca/scenes/:sceneId', asyncRoute(async (request, response) => {
const deleted = await deleteScene(db, routeParam(request, 'sceneId'));
response.sendStatus(deleted ? 204 : 404);
}));
app.get('/api/ca/scenes/:sceneId/resolved', asyncRoute(async (request, response) => {
const resolved = await resolveScene(db, routeParam(request, 'sceneId'));
if (!resolved) {
response.sendStatus(404);
return;
}
response.json(resolved);
}));
app.use((error, _request, response, _next) => {
const message = error instanceof Error ? error.message : 'Unknown error';
response.status(400).json({ error: message });
});
return app;
}
//# sourceMappingURL=caStudioApi.js.map
File diff suppressed because one or more lines are too long
+606
View File
@@ -0,0 +1,606 @@
import { queryOne, queryRequired } from './db.js';
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function mergeSceneParams(base, patch) {
const next = { ...base };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete next[key];
continue;
}
const current = next[key];
if (isPlainObject(current) && isPlainObject(value)) {
next[key] = mergeSceneParams(current, value);
continue;
}
next[key] = value;
}
return next;
}
function parseJsonObject(value) {
if (typeof value === 'string')
return JSON.parse(value);
return (value ?? {});
}
function normalizePresetNode(row) {
return { ...row, params: parseJsonObject(row.params) };
}
function normalizeDeck(row) {
return { ...row, params: parseJsonObject(row.params) };
}
function normalizeScene(row) {
return {
...row,
params: parseJsonObject(row.params),
transition: parseJsonObject(row.transition)
};
}
function normalizeInitialConditionGenerator(row) {
return {
...row,
supported_classes: parseJsonObject(row.supported_classes),
params_schema: parseJsonObject(row.params_schema),
default_params: parseJsonObject(row.default_params)
};
}
function normalizeCaEngine(row) {
return {
...row,
supported_classes: parseJsonObject(row.supported_classes),
params_schema: parseJsonObject(row.params_schema),
default_params: parseJsonObject(row.default_params)
};
}
function normalizeStates(states) {
return Array.isArray(states) ? [...states].sort() : states;
}
function caClassEquals(left, right) {
return (left.dimensions === right.dimensions &&
JSON.stringify(normalizeStates(left.states)) === JSON.stringify(normalizeStates(right.states)) &&
neighborhoodsCompatible(left, right));
}
function neighborhoodsCompatible(left, right) {
if (!left.neighborhoodId || !right.neighborhoodId)
return true;
return left.neighborhoodId === right.neighborhoodId;
}
export async function createPresetTree(db, input) {
return queryRequired(db, `
INSERT INTO ca_preset_trees (slug, name, description, schema_version)
VALUES ($1, $2, $3, $4)
RETURNING *
`, [input.slug, input.name, input.description ?? null, input.schemaVersion ?? 1]);
}
export async function listPresetTrees(db) {
const result = await db.query(`
SELECT
tree.*,
COUNT(node.id)::integer AS node_count
FROM ca_preset_trees tree
LEFT JOIN ca_preset_nodes node
ON node.tree_id = tree.id
AND node.archived_at IS NULL
WHERE tree.archived_at IS NULL
GROUP BY tree.id
ORDER BY tree.created_at DESC, tree.name
`);
return result.rows;
}
export async function deletePresetTree(db, treeId) {
const result = await db.query('DELETE FROM ca_preset_trees WHERE id = $1 RETURNING id', [treeId]);
return result.rows.length > 0;
}
export async function createPresetNode(db, input) {
const row = await queryRequired(db, `
INSERT INTO ca_preset_nodes (
tree_id, parent_id, slug, name, kind, description, notes, sort_order, schema_version, params
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
RETURNING *
`, [
input.treeId,
input.parentId ?? null,
input.slug,
input.name,
input.kind ?? 'scene_base',
input.description ?? null,
input.notes ?? null,
input.sortOrder ?? 0,
input.schemaVersion ?? 1,
JSON.stringify(input.params ?? {})
]);
return normalizePresetNode(row);
}
export async function updatePresetNode(db, treeId, nodeId, patch) {
const existing = await getPresetNode(db, treeId, nodeId);
if (!existing)
return null;
const row = await queryRequired(db, `
UPDATE ca_preset_nodes
SET
parent_id = $3,
slug = $4,
name = $5,
kind = $6,
description = $7,
notes = $8,
sort_order = $9,
schema_version = $10,
params = $11::jsonb,
updated_at = now()
WHERE tree_id = $1 AND id = $2
RETURNING *
`, [
treeId,
nodeId,
Object.hasOwn(patch, 'parentId') ? patch.parentId : existing.parent_id,
patch.slug ?? existing.slug,
patch.name ?? existing.name,
patch.kind ?? existing.kind,
Object.hasOwn(patch, 'description') ? patch.description : existing.description,
Object.hasOwn(patch, 'notes') ? patch.notes : existing.notes,
patch.sortOrder ?? existing.sort_order,
patch.schemaVersion ?? existing.schema_version,
JSON.stringify(patch.params ?? existing.params)
]);
return normalizePresetNode(row);
}
export async function getPresetNode(db, treeId, nodeId) {
const row = await queryOne(db, 'SELECT * FROM ca_preset_nodes WHERE tree_id = $1 AND id = $2', [treeId, nodeId]);
return row ? normalizePresetNode(row) : null;
}
export async function listPresetNodes(db, treeId) {
const result = await db.query(`
SELECT *
FROM ca_preset_nodes
WHERE tree_id = $1
ORDER BY parent_id NULLS FIRST, sort_order, slug
`, [treeId]);
return result.rows.map(normalizePresetNode);
}
export async function listPresetNodeUsage(db, treeId) {
const result = await db.query(`
SELECT preset_node_id AS node_id, count(*) AS scene_count
FROM ca_scenes
WHERE preset_tree_id = $1
AND preset_node_id IS NOT NULL
AND archived_at IS NULL
GROUP BY preset_node_id
`, [treeId]);
return result.rows.map((row) => ({
node_id: row.node_id,
scene_count: Number(row.scene_count)
}));
}
export async function deletePresetNode(db, treeId, nodeId) {
const result = await db.query('DELETE FROM ca_preset_nodes WHERE tree_id = $1 AND id = $2 RETURNING id', [treeId, nodeId]);
return result.rows.length > 0;
}
export async function resolvePresetNode(db, treeId, nodeId) {
const result = await db.query(`
WITH RECURSIVE ancestry AS (
SELECT *, 0 AS depth
FROM ca_preset_nodes
WHERE tree_id = $1 AND id = $2
UNION ALL
SELECT parent.*, child.depth + 1 AS depth
FROM ca_preset_nodes parent
JOIN ancestry child
ON parent.tree_id = child.tree_id
AND parent.id = child.parent_id
)
SELECT *
FROM ancestry
ORDER BY depth DESC
`, [treeId, nodeId]);
if (result.rows.length === 0)
return null;
const ancestry = result.rows.map((row) => normalizePresetNode(row));
const params = ancestry.reduce((resolved, node) => mergeSceneParams(resolved, node.params), {});
return {
treeId,
nodeId,
ancestry,
params
};
}
export async function createInitialConditionGenerator(db, input) {
const row = await queryRequired(db, `
INSERT INTO ca_initial_condition_generators (
slug,
name,
description,
generator_kind,
supported_classes,
params_schema,
default_params
)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb)
RETURNING *
`, [
input.slug,
input.name,
input.description ?? null,
input.generatorKind,
JSON.stringify(input.supportedClasses),
JSON.stringify(input.paramsSchema ?? {}),
JSON.stringify(input.defaultParams ?? {})
]);
return normalizeInitialConditionGenerator(row);
}
export async function listInitialConditionGenerators(db, filter = {}) {
const result = await db.query(`
SELECT *
FROM ca_initial_condition_generators
WHERE archived_at IS NULL
ORDER BY name, slug
`);
const generators = result.rows.map(normalizeInitialConditionGenerator);
if (!filter.caClass)
return generators;
return generators.filter((generator) => generator.supported_classes.some((supportedClass) => caClassEquals(supportedClass, filter.caClass)));
}
export async function getInitialConditionGenerator(db, generatorId) {
const row = await queryOne(db, 'SELECT * FROM ca_initial_condition_generators WHERE id = $1', [generatorId]);
return row ? normalizeInitialConditionGenerator(row) : null;
}
export async function updateInitialConditionGenerator(db, generatorId, patch) {
const existing = await getInitialConditionGenerator(db, generatorId);
if (!existing)
return null;
const row = await queryRequired(db, `
UPDATE ca_initial_condition_generators
SET
slug = $2,
name = $3,
description = $4,
generator_kind = $5,
supported_classes = $6::jsonb,
params_schema = $7::jsonb,
default_params = $8::jsonb,
updated_at = now()
WHERE id = $1
RETURNING *
`, [
generatorId,
patch.slug ?? existing.slug,
patch.name ?? existing.name,
Object.hasOwn(patch, 'description') ? patch.description : existing.description,
patch.generatorKind ?? existing.generator_kind,
JSON.stringify(patch.supportedClasses ?? existing.supported_classes),
JSON.stringify(patch.paramsSchema ?? existing.params_schema),
JSON.stringify(patch.defaultParams ?? existing.default_params)
]);
return normalizeInitialConditionGenerator(row);
}
export async function createCaEngine(db, input) {
const row = await queryRequired(db, `
INSERT INTO ca_engines (
slug,
name,
description,
engine_kind,
license,
owner,
ip_notice,
supported_classes,
params_schema,
default_params
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::jsonb)
RETURNING *
`, [
input.slug,
input.name,
input.description ?? null,
input.engineKind,
input.license ?? null,
input.owner ?? null,
input.ipNotice ?? null,
JSON.stringify(input.supportedClasses),
JSON.stringify(input.paramsSchema ?? {}),
JSON.stringify(input.defaultParams ?? {})
]);
return normalizeCaEngine(row);
}
export async function listCaEngines(db, filter = {}) {
const result = await db.query(`
SELECT *
FROM ca_engines
WHERE archived_at IS NULL
ORDER BY name, slug
`);
const engines = result.rows.map(normalizeCaEngine);
if (!filter.caClass)
return engines;
return engines.filter((engine) => engine.supported_classes.some((supportedClass) => caClassEquals(supportedClass, filter.caClass)));
}
export async function getCaEngine(db, engineId) {
const row = await queryOne(db, 'SELECT * FROM ca_engines WHERE id = $1', [engineId]);
return row ? normalizeCaEngine(row) : null;
}
export async function listCaEnginePresetUsage(db, engineId) {
const engine = await getCaEngine(db, engineId);
if (!engine)
return null;
const result = await db.query(`
SELECT nodes.*, trees.name AS tree_name
FROM ca_preset_nodes nodes
JOIN ca_preset_trees trees ON trees.id = nodes.tree_id
WHERE nodes.archived_at IS NULL
AND trees.archived_at IS NULL
ORDER BY trees.name, nodes.sort_order, nodes.name
`);
const nodes = result.rows.map((row) => ({ ...normalizePresetNode(row), tree_name: row.tree_name }));
const byId = new Map(nodes.map((node) => [`${node.tree_id}:${node.id}`, node]));
const resolvedById = new Map();
const resolving = new Set();
function resolveNode(node) {
const key = `${node.tree_id}:${node.id}`;
const cached = resolvedById.get(key);
if (cached)
return cached;
if (resolving.has(key))
return node.params;
resolving.add(key);
const parent = node.parent_id ? byId.get(`${node.tree_id}:${node.parent_id}`) : undefined;
const resolved = mergeSceneParams(parent ? resolveNode(parent) : {}, node.params);
resolving.delete(key);
resolvedById.set(key, resolved);
return resolved;
}
return nodes
.filter((node) => {
const simulation = resolveNode(node).simulation;
return isPlainObject(simulation) && simulation.engineId === engine.engine_kind;
})
.map((node) => ({
tree_id: node.tree_id,
tree_name: node.tree_name,
node_id: node.id,
node_name: node.name,
node_kind: node.kind
}));
}
export async function updateCaEngine(db, engineId, patch) {
const existing = await getCaEngine(db, engineId);
if (!existing)
return null;
const row = await queryRequired(db, `
UPDATE ca_engines
SET
slug = $2,
name = $3,
description = $4,
engine_kind = $5,
license = $6,
owner = $7,
ip_notice = $8,
supported_classes = $9::jsonb,
params_schema = $10::jsonb,
default_params = $11::jsonb,
updated_at = now()
WHERE id = $1
RETURNING *
`, [
engineId,
patch.slug ?? existing.slug,
patch.name ?? existing.name,
Object.hasOwn(patch, 'description') ? patch.description : existing.description,
patch.engineKind ?? existing.engine_kind,
Object.hasOwn(patch, 'license') ? patch.license : existing.license,
Object.hasOwn(patch, 'owner') ? patch.owner : existing.owner,
Object.hasOwn(patch, 'ipNotice') ? patch.ipNotice : existing.ip_notice,
JSON.stringify(patch.supportedClasses ?? existing.supported_classes),
JSON.stringify(patch.paramsSchema ?? existing.params_schema),
JSON.stringify(patch.defaultParams ?? existing.default_params)
]);
return normalizeCaEngine(row);
}
export async function createDeck(db, input) {
const row = await queryRequired(db, `
INSERT INTO ca_decks (slug, title, description, schema_version, params)
VALUES ($1, $2, $3, $4, $5::jsonb)
RETURNING *
`, [
input.slug,
input.title,
input.description ?? null,
input.schemaVersion ?? 1,
JSON.stringify(input.params ?? {})
]);
return normalizeDeck(row);
}
export async function listDecks(db) {
const result = await db.query(`
SELECT *
FROM ca_decks
WHERE archived_at IS NULL
ORDER BY created_at, slug
`);
return result.rows.map(normalizeDeck);
}
export async function getDeck(db, deckId) {
const row = await queryOne(db, 'SELECT * FROM ca_decks WHERE id = $1', [deckId]);
return row ? normalizeDeck(row) : null;
}
export async function updateDeck(db, deckId, patch) {
const existing = await getDeck(db, deckId);
if (!existing)
return null;
const row = await queryRequired(db, `
UPDATE ca_decks
SET
slug = $2,
title = $3,
description = $4,
schema_version = $5,
params = $6::jsonb,
archived_at = $7,
updated_at = now()
WHERE id = $1
RETURNING *
`, [
deckId,
patch.slug ?? existing.slug,
patch.title ?? existing.title,
Object.hasOwn(patch, 'description') ? patch.description : existing.description,
patch.schemaVersion ?? existing.schema_version,
JSON.stringify(patch.params ?? existing.params),
Object.hasOwn(patch, 'archivedAt') ? patch.archivedAt : existing.archived_at
]);
return normalizeDeck(row);
}
export async function deleteDeck(db, deckId) {
const result = await db.query('DELETE FROM ca_decks WHERE id = $1 RETURNING id', [deckId]);
return result.rows.length > 0;
}
export async function createScene(db, input) {
const row = await queryRequired(db, `
INSERT INTO ca_scenes (
deck_id,
order_index,
title,
description,
preset_tree_id,
preset_node_id,
apply_mode,
requires_previous_scene,
schema_version,
params,
transition
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
RETURNING *
`, [
input.deckId,
input.orderIndex,
input.title,
input.description ?? null,
input.presetTreeId ?? null,
input.presetNodeId ?? null,
input.applyMode ?? 'reinitialize',
input.requiresPreviousScene ?? input.applyMode === 'patch_existing',
input.schemaVersion ?? 1,
JSON.stringify(input.params ?? {}),
JSON.stringify(input.transition ?? {})
]);
return normalizeScene(row);
}
export async function getScene(db, sceneId) {
const row = await queryOne(db, 'SELECT * FROM ca_scenes WHERE id = $1', [sceneId]);
return row ? normalizeScene(row) : null;
}
export async function listScenes(db, deckId) {
const result = await db.query(`
SELECT *
FROM ca_scenes
WHERE deck_id = $1
ORDER BY order_index
`, [deckId]);
return result.rows.map(normalizeScene);
}
export async function updateScene(db, sceneId, patch) {
const existing = await getScene(db, sceneId);
if (!existing)
return null;
const nextApplyMode = patch.applyMode ?? existing.apply_mode;
const nextRequiresPreviousScene = Object.hasOwn(patch, 'requiresPreviousScene')
? patch.requiresPreviousScene
: nextApplyMode === 'patch_existing'
? true
: existing.requires_previous_scene;
const row = await queryRequired(db, `
UPDATE ca_scenes
SET
deck_id = $2,
order_index = $3,
title = $4,
description = $5,
preset_tree_id = $6,
preset_node_id = $7,
apply_mode = $8,
requires_previous_scene = $9,
schema_version = $10,
params = $11::jsonb,
transition = $12::jsonb,
archived_at = $13,
updated_at = now()
WHERE id = $1
RETURNING *
`, [
sceneId,
patch.deckId ?? existing.deck_id,
patch.orderIndex ?? existing.order_index,
patch.title ?? existing.title,
Object.hasOwn(patch, 'description') ? patch.description : existing.description,
Object.hasOwn(patch, 'presetTreeId') ? patch.presetTreeId : existing.preset_tree_id,
Object.hasOwn(patch, 'presetNodeId') ? patch.presetNodeId : existing.preset_node_id,
nextApplyMode,
nextRequiresPreviousScene,
patch.schemaVersion ?? existing.schema_version,
JSON.stringify(patch.params ?? existing.params),
JSON.stringify(patch.transition ?? existing.transition),
Object.hasOwn(patch, 'archivedAt') ? patch.archivedAt : existing.archived_at
]);
return normalizeScene(row);
}
export async function deleteScene(db, sceneId) {
const result = await db.query('DELETE FROM ca_scenes WHERE id = $1 RETURNING id', [sceneId]);
return result.rows.length > 0;
}
export async function resolveScene(db, sceneId) {
const sceneRow = await queryOne(db, 'SELECT * FROM ca_scenes WHERE id = $1', [sceneId]);
if (!sceneRow)
return null;
const scene = normalizeScene(sceneRow);
if (!scene.preset_tree_id || !scene.preset_node_id) {
return {
scene,
ancestry: [],
params: scene.params
};
}
const resolvedNode = await resolvePresetNode(db, scene.preset_tree_id, scene.preset_node_id);
if (!resolvedNode)
return null;
return {
scene,
ancestry: resolvedNode.ancestry,
params: mergeSceneParams(resolvedNode.params, scene.params)
};
}
export async function resolveDeck(db, deckId) {
const deck = await getDeck(db, deckId);
if (!deck)
return null;
const scenes = await listScenes(db, deckId);
const resolvedScenes = [];
for (const scene of scenes) {
if (!scene.preset_tree_id || !scene.preset_node_id) {
resolvedScenes.push({
scene,
ancestry: [],
params: scene.params
});
continue;
}
const resolvedNode = await resolvePresetNode(db, scene.preset_tree_id, scene.preset_node_id);
if (!resolvedNode) {
throw new Error(`Unable to resolve preset node for scene ${scene.id}`);
}
resolvedScenes.push({
scene,
ancestry: resolvedNode.ancestry,
params: mergeSceneParams(resolvedNode.params, scene.params)
});
}
return {
deck,
scenes: resolvedScenes
};
}
//# sourceMappingURL=caStudioRepository.js.map
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
export async function queryOne(db, sql, params = []) {
const result = await db.query(sql, params);
return result.rows[0] ?? null;
}
export async function queryRequired(db, sql, params = []) {
const row = await queryOne(db, sql, params);
if (!row) {
throw new Error('Expected database row, received none');
}
return row;
}
//# sourceMappingURL=db.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAM,EAAa,EAAE,GAAW,EAAE,SAAoB,EAAE;IACpF,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,KAAK,CAAM,GAAG,EAAE,MAAM,CAAC,CAAA;IAC/C,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAA;AAC/B,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAM,EAAa,EAAE,GAAW,EAAE,SAAoB,EAAE;IACzF,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAM,EAAE,EAAE,GAAG,EAAE,MAAM,CAAC,CAAA;IAChD,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAA;IACzD,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC"}
+13
View File
@@ -0,0 +1,13 @@
import { Pool } from 'pg';
import { createCaStudioApi } from './caStudioApi.js';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required');
}
const pool = new Pool({ connectionString: databaseUrl });
const app = createCaStudioApi(pool);
const port = Number(process.env.PORT ?? 3100);
app.listen(port, () => {
console.log(`CA Studio API listening on http://localhost:${port}`);
});
//# sourceMappingURL=server.js.map
+1
View File
@@ -0,0 +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"}
+4426
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@glitch-components/voxel-automata-lab-backend",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"admin:build": "vite build --config admin/vite.config.ts",
"admin:dev": "vite --config admin/vite.config.ts",
"build": "npm run admin:build && npm run server:build",
"dev": "tsx src/server.ts",
"migrate": "node scripts/migrate.mjs",
"server:build": "tsc -p tsconfig.build.json",
"start": "node dist/server.js",
"test": "vitest run",
"test:postgres": "CA_STUDIO_TEST_DATABASE_URL=${CA_STUDIO_TEST_DATABASE_URL:-postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test} vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"express": "^5.2.1",
"pg": "^8.16.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"three": "^0.185.0"
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.3",
"@types/express": "^5.0.6",
"@types/node": "^24.10.2",
"@types/pg": "^8.15.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/supertest": "^6.0.3",
"@types/three": "^0.185.0",
"@vitejs/plugin-react": "^5.1.2",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.2.7",
"vitest": "^4.1.9"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<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-BXPDm7zD.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-DN6CRbyT.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
import { readdir, readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Pool } from 'pg'
const __dirname = dirname(fileURLToPath(import.meta.url))
const migrationsDir = resolve(__dirname, '../src/migrations')
const connectionString =
process.env.DATABASE_URL ?? 'postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test'
const pool = new Pool({ connectionString })
try {
const migrationFiles = (await readdir(migrationsDir))
.filter((file) => file.endsWith('.sql'))
.sort()
for (const file of migrationFiles) {
const migrationPath = resolve(migrationsDir, file)
const sql = await readFile(migrationPath, 'utf8')
await pool.query(sql)
console.log(`Applied migration ${migrationPath}`)
}
} finally {
await pool.end()
}
+321
View File
@@ -0,0 +1,321 @@
import express from 'express'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Queryable } from './db.js'
import {
createCaEngine,
createDeck,
createInitialConditionGenerator,
createPresetNode,
createPresetTree,
createScene,
deleteDeck,
deletePresetNode,
deletePresetTree,
deleteScene,
getCaEngine,
getDeck,
getInitialConditionGenerator,
getPresetNode,
getScene,
listCaEngines,
listCaEnginePresetUsage,
listDecks,
listInitialConditionGenerators,
listPresetNodeUsage,
listPresetNodes,
listPresetTrees,
listScenes,
resolveDeck,
resolvePresetNode,
resolveScene,
updateCaEngine,
updateDeck,
updateInitialConditionGenerator,
updatePresetNode,
updateScene
} from './caStudioRepository.js'
import type { CaClass } from './caStudioRepository.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const adminPublicPath = resolve(__dirname, '../public/admin')
function asyncRoute(
handler: (request: express.Request, response: express.Response) => Promise<void>
) {
return (request: express.Request, response: express.Response, next: express.NextFunction) => {
handler(request, response).catch(next)
}
}
function routeParam(request: express.Request, key: string) {
const value = request.params[key]
if (typeof value !== 'string') {
throw new Error(`Missing route parameter: ${key}`)
}
return value
}
function queryString(value: unknown) {
return typeof value === 'string' ? value : undefined
}
function parseStates(value: string): CaClass['states'] {
const trimmed = value.trim()
if (/^\d+$/.test(trimmed)) return Number(trimmed)
if (trimmed.startsWith('[')) return JSON.parse(trimmed) as string[]
return trimmed.split(',').map((state) => state.trim()).filter(Boolean)
}
function caClassFromQuery(query: express.Request['query']) {
const dimensions = queryString(query.dimensions)
const states = queryString(query.states)
if (!dimensions && !states) return undefined
if (!dimensions || !states) {
throw new Error('Filtering initial condition generators requires dimensions and states')
}
return {
dimensions: Number(dimensions),
states: parseStates(states)
}
}
export function createCaStudioApi(db: Queryable) {
const app = express()
app.use(express.json({ limit: '1mb' }))
app.use('/admin', express.static(adminPublicPath))
app.get('/', (_request, response) => {
response.redirect('/admin')
})
app.get(/^\/(?:admin|view)(?:\/.*)?$/, (_request, response) => {
response.sendFile(resolve(adminPublicPath, 'index.html'))
})
app.post('/api/ca/preset-trees', asyncRoute(async (request, response) => {
const tree = await createPresetTree(db, request.body)
response.status(201).json({ ...tree, node_count: 0 })
}))
app.get('/api/ca/preset-trees', asyncRoute(async (_request, response) => {
response.json(await listPresetTrees(db))
}))
app.delete('/api/ca/preset-trees/:treeId', asyncRoute(async (request, response) => {
const deleted = await deletePresetTree(db, routeParam(request, 'treeId'))
response.sendStatus(deleted ? 204 : 404)
}))
app.get('/api/ca/preset-trees/:treeId/nodes', asyncRoute(async (request, response) => {
response.json(await listPresetNodes(db, routeParam(request, 'treeId')))
}))
app.post('/api/ca/preset-trees/:treeId/nodes', asyncRoute(async (request, response) => {
const node = await createPresetNode(db, {
...request.body,
treeId: routeParam(request, 'treeId')
})
response.status(201).json(node)
}))
app.get('/api/ca/preset-trees/:treeId/nodes/:nodeId/resolved', asyncRoute(async (request, response) => {
const resolved = await resolvePresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'))
if (!resolved) {
response.sendStatus(404)
return
}
response.json(resolved)
}))
app.get('/api/ca/preset-trees/:treeId/nodes/:nodeId', asyncRoute(async (request, response) => {
const node = await getPresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'))
if (!node) {
response.sendStatus(404)
return
}
response.json(node)
}))
app.get('/api/ca/preset-trees/:treeId/node-usage', asyncRoute(async (request, response) => {
response.json(await listPresetNodeUsage(db, routeParam(request, 'treeId')))
}))
app.patch('/api/ca/preset-trees/:treeId/nodes/:nodeId', asyncRoute(async (request, response) => {
const node = await updatePresetNode(
db,
routeParam(request, 'treeId'),
routeParam(request, 'nodeId'),
request.body
)
if (!node) {
response.sendStatus(404)
return
}
response.json(node)
}))
app.delete('/api/ca/preset-trees/:treeId/nodes/:nodeId', asyncRoute(async (request, response) => {
const deleted = await deletePresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'))
response.sendStatus(deleted ? 204 : 404)
}))
app.post('/api/ca/initial-condition-generators', asyncRoute(async (request, response) => {
response.status(201).json(await createInitialConditionGenerator(db, request.body))
}))
app.get('/api/ca/initial-condition-generators', asyncRoute(async (request, response) => {
response.json(await listInitialConditionGenerators(db, { caClass: caClassFromQuery(request.query) }))
}))
app.get('/api/ca/initial-condition-generators/:generatorId', asyncRoute(async (request, response) => {
const generator = await getInitialConditionGenerator(db, routeParam(request, 'generatorId'))
if (!generator) {
response.sendStatus(404)
return
}
response.json(generator)
}))
app.patch('/api/ca/initial-condition-generators/:generatorId', asyncRoute(async (request, response) => {
const generator = await updateInitialConditionGenerator(db, routeParam(request, 'generatorId'), request.body)
if (!generator) {
response.sendStatus(404)
return
}
response.json(generator)
}))
app.post('/api/ca/engines', asyncRoute(async (request, response) => {
response.status(201).json(await createCaEngine(db, request.body))
}))
app.get('/api/ca/engines', asyncRoute(async (request, response) => {
response.json(await listCaEngines(db, { caClass: caClassFromQuery(request.query) }))
}))
app.get('/api/ca/engines/:engineId/usage', asyncRoute(async (request, response) => {
const usage = await listCaEnginePresetUsage(db, routeParam(request, 'engineId'))
if (!usage) {
response.sendStatus(404)
return
}
response.json(usage)
}))
app.get('/api/ca/engines/:engineId', asyncRoute(async (request, response) => {
const engine = await getCaEngine(db, routeParam(request, 'engineId'))
if (!engine) {
response.sendStatus(404)
return
}
response.json(engine)
}))
app.patch('/api/ca/engines/:engineId', asyncRoute(async (request, response) => {
const engine = await updateCaEngine(db, routeParam(request, 'engineId'), request.body)
if (!engine) {
response.sendStatus(404)
return
}
response.json(engine)
}))
app.post('/api/ca/decks', asyncRoute(async (request, response) => {
response.status(201).json(await createDeck(db, request.body))
}))
app.get('/api/ca/decks', asyncRoute(async (_request, response) => {
response.json(await listDecks(db))
}))
app.get('/api/ca/decks/:deckId', asyncRoute(async (request, response) => {
const deck = await getDeck(db, routeParam(request, 'deckId'))
if (!deck) {
response.sendStatus(404)
return
}
response.json(deck)
}))
app.patch('/api/ca/decks/:deckId', asyncRoute(async (request, response) => {
const deck = await updateDeck(db, routeParam(request, 'deckId'), request.body)
if (!deck) {
response.sendStatus(404)
return
}
response.json(deck)
}))
app.delete('/api/ca/decks/:deckId', asyncRoute(async (request, response) => {
const deleted = await deleteDeck(db, routeParam(request, 'deckId'))
response.sendStatus(deleted ? 204 : 404)
}))
app.get('/api/ca/decks/:deckId/resolved', asyncRoute(async (request, response) => {
const resolved = await resolveDeck(db, routeParam(request, 'deckId'))
if (!resolved) {
response.sendStatus(404)
return
}
response.json(resolved)
}))
app.get('/api/ca/decks/:deckId/scenes', asyncRoute(async (request, response) => {
response.json(await listScenes(db, routeParam(request, 'deckId')))
}))
app.post('/api/ca/decks/:deckId/scenes', asyncRoute(async (request, response) => {
const scene = await createScene(db, {
...request.body,
deckId: routeParam(request, 'deckId')
})
response.status(201).json(scene)
}))
app.get('/api/ca/scenes/:sceneId', asyncRoute(async (request, response) => {
const scene = await getScene(db, routeParam(request, 'sceneId'))
if (!scene) {
response.sendStatus(404)
return
}
response.json(scene)
}))
app.patch('/api/ca/scenes/:sceneId', asyncRoute(async (request, response) => {
const scene = await updateScene(db, routeParam(request, 'sceneId'), request.body)
if (!scene) {
response.sendStatus(404)
return
}
response.json(scene)
}))
app.delete('/api/ca/scenes/:sceneId', asyncRoute(async (request, response) => {
const deleted = await deleteScene(db, routeParam(request, 'sceneId'))
response.sendStatus(deleted ? 204 : 404)
}))
app.get('/api/ca/scenes/:sceneId/resolved', asyncRoute(async (request, response) => {
const resolved = await resolveScene(db, routeParam(request, 'sceneId'))
if (!resolved) {
response.sendStatus(404)
return
}
response.json(resolved)
}))
app.use((
error: unknown,
_request: express.Request,
response: express.Response,
_next: express.NextFunction
) => {
const message = error instanceof Error ? error.message : 'Unknown error'
response.status(400).json({ error: message })
})
return app
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
export interface QueryResult<Row = Record<string, unknown>> {
rows: Row[]
rowCount?: number | null
}
export interface Queryable {
query<Row = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<QueryResult<Row>>
}
export async function queryOne<Row>(db: Queryable, sql: string, params: unknown[] = []) {
const result = await db.query<Row>(sql, params)
return result.rows[0] ?? null
}
export async function queryRequired<Row>(db: Queryable, sql: string, params: unknown[] = []) {
const row = await queryOne<Row>(db, sql, params)
if (!row) {
throw new Error('Expected database row, received none')
}
return row
}
+163
View File
@@ -0,0 +1,163 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'ca_scene_apply_mode') THEN
CREATE TYPE ca_scene_apply_mode AS ENUM ('reinitialize', 'patch_existing');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS ca_preset_trees (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
description text,
schema_version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz
);
CREATE TABLE IF NOT EXISTS ca_preset_nodes (
id uuid NOT NULL DEFAULT gen_random_uuid(),
tree_id uuid NOT NULL,
parent_id uuid,
slug text NOT NULL,
name text NOT NULL,
kind text NOT NULL DEFAULT 'scene_base',
description text,
notes text,
sort_order integer NOT NULL DEFAULT 0,
schema_version integer NOT NULL DEFAULT 1,
params jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz,
PRIMARY KEY (tree_id, id),
CONSTRAINT ca_preset_nodes_tree_fk
FOREIGN KEY (tree_id)
REFERENCES ca_preset_trees(id)
ON DELETE CASCADE,
CONSTRAINT ca_preset_nodes_parent_fk
FOREIGN KEY (tree_id, parent_id)
REFERENCES ca_preset_nodes(tree_id, id)
ON DELETE CASCADE,
CONSTRAINT ca_preset_nodes_dimension_unique
UNIQUE (tree_id, parent_id, id),
CONSTRAINT ca_preset_nodes_kind_check
CHECK (kind IN (
'preset_root',
'chapter',
'scene_base',
'rule',
'renderer',
'camera',
'overlay',
'shot',
'debug'
))
);
CREATE UNIQUE INDEX IF NOT EXISTS ca_preset_nodes_root_slug_unique
ON ca_preset_nodes(tree_id, slug)
WHERE parent_id IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS ca_preset_nodes_sibling_slug_unique
ON ca_preset_nodes(tree_id, parent_id, slug)
WHERE parent_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ca_preset_nodes_tree_parent_sort_idx
ON ca_preset_nodes(tree_id, parent_id, sort_order, slug);
CREATE OR REPLACE FUNCTION ca_prevent_preset_node_cycle()
RETURNS trigger AS $$
BEGIN
IF NEW.parent_id IS NULL THEN
RETURN NEW;
END IF;
IF NEW.parent_id = NEW.id THEN
RAISE EXCEPTION 'Preset node cannot be its own parent';
END IF;
IF EXISTS (
WITH RECURSIVE ancestors AS (
SELECT tree_id, id, parent_id
FROM ca_preset_nodes
WHERE tree_id = NEW.tree_id AND id = NEW.parent_id
UNION ALL
SELECT parent.tree_id, parent.id, parent.parent_id
FROM ca_preset_nodes parent
JOIN ancestors child
ON parent.tree_id = child.tree_id
AND parent.id = child.parent_id
)
SELECT 1 FROM ancestors WHERE id = NEW.id
) THEN
RAISE EXCEPTION 'Preset node parent would create a cycle';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ca_preset_nodes_prevent_cycle ON ca_preset_nodes;
CREATE TRIGGER ca_preset_nodes_prevent_cycle
BEFORE INSERT OR UPDATE OF parent_id, tree_id
ON ca_preset_nodes
FOR EACH ROW
EXECUTE FUNCTION ca_prevent_preset_node_cycle();
CREATE TABLE IF NOT EXISTS ca_decks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
title text NOT NULL,
description text,
schema_version integer NOT NULL DEFAULT 1,
params jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz
);
CREATE TABLE IF NOT EXISTS ca_scenes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
deck_id uuid NOT NULL,
order_index integer NOT NULL,
title text NOT NULL,
description text,
preset_tree_id uuid NOT NULL,
preset_node_id uuid NOT NULL,
apply_mode ca_scene_apply_mode NOT NULL DEFAULT 'reinitialize',
requires_previous_scene boolean NOT NULL DEFAULT false,
schema_version integer NOT NULL DEFAULT 1,
params jsonb NOT NULL DEFAULT '{}'::jsonb,
transition jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz,
CONSTRAINT ca_scenes_deck_fk
FOREIGN KEY (deck_id)
REFERENCES ca_decks(id)
ON DELETE CASCADE,
CONSTRAINT ca_scenes_preset_node_fk
FOREIGN KEY (preset_tree_id, preset_node_id)
REFERENCES ca_preset_nodes(tree_id, id)
ON DELETE RESTRICT,
CONSTRAINT ca_scenes_deck_order_unique
UNIQUE (deck_id, order_index),
CONSTRAINT ca_scenes_patch_requires_previous_check
CHECK (apply_mode = 'reinitialize' OR requires_previous_scene = true)
);
CREATE INDEX IF NOT EXISTS ca_scenes_deck_order_idx
ON ca_scenes(deck_id, order_index);
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS ca_initial_condition_generators (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
description text,
generator_kind text NOT NULL,
supported_classes jsonb NOT NULL DEFAULT '[]'::jsonb,
params_schema jsonb NOT NULL DEFAULT '{}'::jsonb,
default_params jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz,
CONSTRAINT ca_icg_supported_classes_array_check
CHECK (jsonb_typeof(supported_classes) = 'array'),
CONSTRAINT ca_icg_supported_classes_nonempty_check
CHECK (jsonb_array_length(supported_classes) > 0)
);
CREATE INDEX IF NOT EXISTS ca_icg_supported_classes_gin_idx
ON ca_initial_condition_generators
USING gin (supported_classes);
+26
View File
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS ca_engines (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
description text,
engine_kind text NOT NULL,
license text,
owner text,
ip_notice text,
supported_classes jsonb NOT NULL DEFAULT '[]'::jsonb,
params_schema jsonb NOT NULL DEFAULT '{}'::jsonb,
default_params jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz,
CONSTRAINT ca_engines_supported_classes_array_check
CHECK (jsonb_typeof(supported_classes) = 'array'),
CONSTRAINT ca_engines_supported_classes_nonempty_check
CHECK (jsonb_array_length(supported_classes) > 0)
);
CREATE INDEX IF NOT EXISTS ca_engines_supported_classes_gin_idx
ON ca_engines
USING gin (supported_classes);
@@ -0,0 +1,25 @@
INSERT INTO ca_engines (
slug,
name,
description,
engine_kind,
license,
owner,
ip_notice,
supported_classes,
params_schema,
default_params
)
VALUES (
'conway-life',
'Conway Life',
'Classic binary cellular automaton evolution function using B3/S23.',
'game-of-life-2d',
'public-domain-ruleset',
'Glitch University',
'Registry entry for the classic Conway Life rule; custom engine implementations may carry separate licensing.',
'[{"dimensions":2,"states":2}]'::jsonb,
'{"ruleId":{"type":"string","label":"Rule","default":"B3/S23"},"rendererId":{"type":"string","label":"Renderer","default":"2d-canvas"}}'::jsonb,
'{"ruleId":"B3/S23","rendererId":"2d-canvas"}'::jsonb
)
ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1,50 @@
INSERT INTO ca_engines (
slug,
name,
description,
engine_kind,
license,
owner,
ip_notice,
supported_classes,
params_schema,
default_params
)
VALUES
(
'highlife',
'HighLife',
'Outer-totalistic binary 2D CA using B36/S23.',
'outer-totalistic-2d',
'ruleset-reference',
'Glitch University',
'Registry metadata for a known Life-like ruleset; implementation licensing is tracked separately.',
'[{"dimensions":2,"states":2}]'::jsonb,
'{"ruleId":{"type":"string","label":"Rule","default":"B36/S23"},"rendererId":{"type":"string","label":"Renderer","default":"2d-canvas"}}'::jsonb,
'{"ruleId":"B36/S23","rendererId":"2d-canvas"}'::jsonb
),
(
'seeds',
'Seeds',
'Outer-totalistic binary 2D CA using B2/S.',
'outer-totalistic-2d',
'ruleset-reference',
'Glitch University',
'Registry metadata for a known Life-like ruleset; implementation licensing is tracked separately.',
'[{"dimensions":2,"states":2}]'::jsonb,
'{"ruleId":{"type":"string","label":"Rule","default":"B2/S"},"rendererId":{"type":"string","label":"Renderer","default":"2d-canvas"}}'::jsonb,
'{"ruleId":"B2/S","rendererId":"2d-canvas"}'::jsonb
),
(
'day-and-night',
'Day & Night',
'Outer-totalistic binary 2D CA using B3678/S34678.',
'outer-totalistic-2d',
'ruleset-reference',
'Glitch University',
'Registry metadata for a known Life-like ruleset; implementation licensing is tracked separately.',
'[{"dimensions":2,"states":2}]'::jsonb,
'{"ruleId":{"type":"string","label":"Rule","default":"B3678/S34678"},"rendererId":{"type":"string","label":"Renderer","default":"2d-canvas"}}'::jsonb,
'{"ruleId":"B3678/S34678","rendererId":"2d-canvas"}'::jsonb
)
ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1,50 @@
INSERT INTO ca_engines (
slug,
name,
description,
engine_kind,
license,
owner,
ip_notice,
supported_classes,
params_schema,
default_params
)
VALUES
(
'elementary-rule-110',
'Elementary Rule 110',
'Elementary one-dimensional binary CA using Stephen Wolfram style rule numbers.',
'elementary-1d',
'ruleset-reference',
'Glitch University',
'Registry metadata for elementary CA rules; custom engine implementations may carry separate licensing.',
'[{"dimensions":1,"states":2}]'::jsonb,
'{"ruleId":{"type":"string","label":"Rule","default":"Rule 110"},"rendererId":{"type":"string","label":"Renderer","default":"elementary-1d"}}'::jsonb,
'{"ruleId":"Rule 110","rendererId":"elementary-1d"}'::jsonb
),
(
'elementary-rule-30',
'Elementary Rule 30',
'Elementary one-dimensional binary CA using Rule 30.',
'elementary-1d',
'ruleset-reference',
'Glitch University',
'Registry metadata for elementary CA rules; custom engine implementations may carry separate licensing.',
'[{"dimensions":1,"states":2}]'::jsonb,
'{"ruleId":{"type":"string","label":"Rule","default":"Rule 30"},"rendererId":{"type":"string","label":"Renderer","default":"elementary-1d"}}'::jsonb,
'{"ruleId":"Rule 30","rendererId":"elementary-1d"}'::jsonb
),
(
'binary-wildfire',
'Binary Wildfire',
'Binary 2D wildfire spread model where burning cells cool and adjacent fuel ignites deterministically.',
'wildfire-2d',
'internal',
'Glitch University',
'Internal pedagogical wildfire engine; richer multi-state wildfire models should declare their own engine metadata.',
'[{"dimensions":2,"states":2}]'::jsonb,
'{"ruleId":{"type":"string","label":"Rule","default":"wildfire-binary"},"rendererId":{"type":"string","label":"Renderer","default":"wildfire-2d"},"spreadProbability":{"type":"range","label":"Spread probability","default":1,"min":0,"max":1,"step":0.01},"seed":{"type":"string","label":"Seed","default":"wildfire"}}'::jsonb,
'{"ruleId":"wildfire-binary","rendererId":"wildfire-2d","spreadProbability":1,"seed":"wildfire"}'::jsonb
)
ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1,14 @@
ALTER TABLE ca_scenes
ALTER COLUMN preset_tree_id DROP NOT NULL,
ALTER COLUMN preset_node_id DROP NOT NULL;
ALTER TABLE ca_scenes
DROP CONSTRAINT IF EXISTS ca_scenes_preset_reference_pair_check;
ALTER TABLE ca_scenes
ADD CONSTRAINT ca_scenes_preset_reference_pair_check
CHECK (
(preset_tree_id IS NULL AND preset_node_id IS NULL)
OR
(preset_tree_id IS NOT NULL AND preset_node_id IS NOT NULL)
);
@@ -0,0 +1,41 @@
UPDATE ca_engines
SET
params_schema = jsonb_build_object(
'ruleId',
jsonb_build_object(
'type', 'select',
'label', 'Rule',
'default', default_params->>'ruleId',
'options', jsonb_build_array(
jsonb_build_object(
'value', default_params->>'ruleId',
'label',
CASE default_params->>'ruleId'
WHEN 'wildfire-binary' THEN 'Binary wildfire'
WHEN 'life-3d' THEN '3D Life'
WHEN 'generations-3d' THEN '3D Generations'
WHEN 'lattice-gas-3d' THEN '3D Lattice gas'
WHEN 'snake-3d' THEN '3D Snake'
ELSE default_params->>'ruleId'
END
)
)
),
'rendererId',
jsonb_build_object(
'type', 'select',
'label', 'Renderer',
'default', default_params->>'rendererId',
'options',
CASE
WHEN supported_classes @> '[{"dimensions":1}]'::jsonb THEN
'[{"value":"elementary-1d","label":"Elementary 1D"}]'::jsonb
WHEN supported_classes @> '[{"dimensions":3}]'::jsonb THEN
'[{"value":"voxel-3d","label":"Voxel 3D"}]'::jsonb
ELSE
'[{"value":"2d-canvas","label":"2D Canvas"},{"value":"wildfire-2d","label":"Wildfire 2D"}]'::jsonb
END
)
) || (params_schema - 'ruleId' - 'rendererId')
WHERE default_params ? 'ruleId'
AND default_params ? 'rendererId';
+16
View File
@@ -0,0 +1,16 @@
import { Pool } from 'pg'
import { createCaStudioApi } from './caStudioApi.js'
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
throw new Error('DATABASE_URL is required')
}
const pool = new Pool({ connectionString: databaseUrl })
const app = createCaStudioApi(pool)
const port = Number(process.env.PORT ?? 3100)
app.listen(port, () => {
console.log(`CA Studio API listening on http://localhost:${port}`)
})
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import { listCaEngineRuntimes, registerCaEngineRuntime, stepCaCells } 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 {
const grid = Array.from({ length: height }, () => Array.from({ length: width }, () => false))
for (const [x, y] of alive) grid[y][x] = true
return grid
}
function settings(params: SceneParams = {}): SceneParams {
return {
...params,
simulation: {
engineId: 'game-of-life-2d',
ruleId: 'B3/S23',
neighborhoodId: 'moore',
...params.simulation
}
}
}
describe('CA engine runtimes', () => {
it('registers built-in runtimes that can be selected by engine id', () => {
expect(listCaEngineRuntimes().map((engine) => engine.id)).toEqual(
expect.arrayContaining([
'game-of-life-2d',
'outer-totalistic-2d',
'elementary-1d',
'wildfire-2d',
'generic-voxel-ca',
'noop'
])
)
})
it('allows a drop-in engine to register and step cells through the shared contract', () => {
registerCaEngineRuntime({
id: 'test-fill-runtime',
label: 'Test fill runtime',
step: (current) => current.map((row) => row.map(() => true))
})
expect(stepCaCells(cells(2, 2), settings({ simulation: { engineId: 'test-fill-runtime' } }))).toEqual([
[true, true],
[true, true]
])
})
it('rejects duplicate engine runtime ids', () => {
expect(() =>
registerCaEngineRuntime({
id: 'game-of-life-2d',
label: 'Duplicate Life',
step: (current) => current
})
).toThrow('already registered')
})
it('rejects engine output that changes the grid contract', () => {
registerCaEngineRuntime({
id: 'test-invalid-shape-runtime',
label: 'Test invalid shape runtime',
step: () => [[true]]
})
expect(() => stepCaCells(cells(2, 2), settings({ simulation: { engineId: 'test-invalid-shape-runtime' } }))).toThrow(
'preserve the input grid dimensions'
)
})
it('does not mutate the input cell grid while stepping', () => {
const current = cells(3, 3, [
[0, 1],
[1, 1],
[2, 1]
])
const before = current.map((row) => [...row])
const next = stepCaCells(current, settings())
expect(current).toEqual(before)
expect(next).not.toBe(current)
expect(next[0]).not.toBe(current[0])
})
it('steps Conway Life oscillators with the outer-totalistic B/S rule contract', () => {
const blinker = cells(5, 5, [
[2, 1],
[2, 2],
[2, 3]
])
expect(stepCaCells(blinker, settings())).toEqual(
cells(5, 5, [
[1, 2],
[2, 2],
[3, 2]
])
)
})
it('steps elementary 1D rules on the active source row', () => {
const current = cells(3, 3, [[1, 0]])
expect(
stepCaCells(current, settings({ simulation: { engineId: 'elementary-1d', ruleId: 'Rule 90' } }))
).toEqual(cells(3, 3, [
[0, 0],
[2, 0]
]))
})
it('uses fixed boundaries for elementary 1D edge samples when requested', () => {
const current = cells(3, 3, [[0, 0]])
expect(
stepCaCells(current, settings({ simulation: { engineId: 'elementary-1d', ruleId: 'Rule 90', grid: { boundary: 'fixed' } } }))
).toEqual(cells(3, 3, [[1, 0]]))
})
it('steps binary wildfire by cooling burning cells and igniting von Neumann neighbours', () => {
const current = cells(3, 3, [[1, 1]])
expect(
stepCaCells(current, settings({ simulation: { engineId: 'wildfire-2d', spreadProbability: 1 } }))
).toEqual(cells(3, 3, [
[1, 0],
[0, 1],
[2, 1],
[1, 2]
]))
})
it('lets binary wildfire suppress spread with deterministic probability settings', () => {
const current = cells(3, 3, [[1, 1]])
expect(
stepCaCells(current, settings({ simulation: { engineId: 'wildfire-2d', spreadProbability: 0 } }))
).toEqual(cells(3, 3))
})
it('defaults boundaries to wrap when the node does not specify a boundary condition', () => {
const current = cells(3, 3, [
[2, 2],
[2, 0],
[0, 2]
])
expect(stepCaCells(current, settings())[0][0]).toBe(true)
})
it('supports fixed boundaries and legacy wrap=false nodes', () => {
const current = cells(3, 3, [
[2, 2],
[2, 0],
[0, 2]
])
expect(stepCaCells(current, settings({ simulation: { grid: { boundary: 'fixed' } } }))[0][0]).toBe(false)
expect(stepCaCells(current, settings({ simulation: { grid: { wrap: false } } }))[0][0]).toBe(false)
})
it('supports mirror boundaries by reflecting out-of-bounds neighbour samples', () => {
const current = cells(3, 3, [
[0, 0],
[1, 0],
[0, 1]
])
expect(stepCaCells(current, settings({ simulation: { grid: { boundary: 'fixed' } } }))[0][0]).toBe(true)
expect(stepCaCells(current, settings({ simulation: { grid: { boundary: 'mirror' } } }))[0][0]).toBe(false)
})
})
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
generateInitialCondition,
getInitialConditionRuntime,
listInitialConditionRuntimes,
registerInitialConditionRuntime
} from '../admin/src/caInitialConditionGenerators.js'
describe('CA initial condition runtimes', () => {
it('registers the built-in random soup generator', () => {
expect(listInitialConditionRuntimes().map((runtime) => runtime.id)).toContain('random-soup')
expect(getInitialConditionRuntime('random-soup')?.label).toBe('Random soup')
})
it('generates deterministic cell coordinates for the same seed and density', () => {
const input = {
caClass: { dimensions: 2, states: 2 },
settings: { simulation: { grid: { size: [5, 5, 1] } } },
params: { density: 0.35, seed: 'repeatable' }
}
expect(generateInitialCondition('random-soup', input)).toEqual(generateInitialCondition('random-soup', input))
})
it('clamps density and records generator provenance in the persisted initial condition', () => {
const generated = generateInitialCondition('random-soup', {
caClass: { dimensions: 2, states: 2 },
settings: { simulation: { grid: { size: [3, 3, 1] } } },
params: { density: 2, seed: 'full' }
})
expect(generated.cells).toHaveLength(9)
expect(generated.generator).toEqual({ kind: 'random-soup', density: 1, seed: 'full' })
})
it('lets custom initial condition runtimes plug into the same contract', () => {
registerInitialConditionRuntime({
id: 'test-single-cell',
label: 'Test single cell',
generate: () => ({ type: 'cells', cells: [[1, 1, 0]], generator: { kind: 'test-single-cell' } })
})
expect(generateInitialCondition('test-single-cell', {
caClass: { dimensions: 2, states: 2 },
settings: {},
params: {}
}).cells).toEqual([[1, 1, 0]])
})
it('rejects duplicate runtime ids and missing implementations', () => {
expect(() =>
registerInitialConditionRuntime({
id: 'random-soup',
label: 'Duplicate random soup',
generate: () => ({ type: 'cells', cells: [], generator: { kind: 'random-soup' } })
})
).toThrow('already registered')
expect(() =>
generateInitialCondition('missing-generator', {
caClass: { dimensions: 2, states: 2 },
settings: {},
params: {}
})
).toThrow('unavailable')
})
})
+76
View File
@@ -0,0 +1,76 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import {
CaRenderer,
getCaRendererRuntime,
listCompatibleCaRendererRuntimes,
listCaRendererRuntimes,
registerCaRendererRuntime
} from '../admin/src/CaRenderer.js'
describe('CA renderer runtimes', () => {
it('registers built-in renderers and resolves aliases', () => {
expect(listCaRendererRuntimes().map((runtime) => runtime.id)).toEqual(
expect.arrayContaining(['2d-canvas', 'elementary-1d', 'wildfire-2d', 'voxel-3d'])
)
expect(getCaRendererRuntime('three-voxel')?.id).toBe('voxel-3d')
expect(getCaRendererRuntime('elementary-ca')?.id).toBe('elementary-1d')
expect(getCaRendererRuntime('forest-fire-2d')?.id).toBe('wildfire-2d')
})
it('lets custom renderers register with the shared renderer contract', () => {
registerCaRendererRuntime({
id: 'test-renderer',
label: 'Test renderer',
supportedClasses: [{ dimensions: 2, states: 3 }],
Component: function TestRenderer() {
return React.createElement('div', null)
}
})
expect(getCaRendererRuntime('test-renderer')?.label).toBe('Test renderer')
})
it('filters renderers by dimensionality and state space', () => {
expect(listCompatibleCaRendererRuntimes({ dimensions: 3, states: 2 }).map((runtime) => runtime.id)).toEqual([
'voxel-3d'
])
expect(listCompatibleCaRendererRuntimes({ dimensions: 1, states: 2 }).map((runtime) => runtime.id)).toEqual([
'elementary-1d'
])
expect(listCompatibleCaRendererRuntimes({ dimensions: 2, states: 3 }).map((runtime) => runtime.id)).toContain(
'test-renderer'
)
})
it('refuses to render a runtime for an incompatible CA space', () => {
const markup = renderToStaticMarkup(
React.createElement(CaRenderer, {
caption: '',
cells: [[false]],
settings: {
caClass: { dimensions: 3, states: 2 },
renderer: { id: '2d-canvas' }
},
onCellsChange: () => undefined
})
)
expect(markup).toContain('does not support this CA space')
expect(markup).not.toContain('Game of Life cell editor')
})
it('rejects duplicate renderer ids', () => {
expect(() =>
registerCaRendererRuntime({
id: '2d-canvas',
label: 'Duplicate canvas',
supportedClasses: [{ dimensions: 2, states: 2 }],
Component: function DuplicateRenderer() {
return React.createElement('div', null)
}
})
).toThrow('already registered')
})
})
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import {
assertSameCellsShape,
getBoundaryCondition,
getCaClassFromParams,
numericGridSize,
resolveBoundaryIndex,
supportsCaClass
} from '../admin/src/caRuntime.js'
import type { Cells } from '../admin/src/types.js'
describe('CA runtime helpers', () => {
it('extracts CA class metadata from resolved scene params', () => {
expect(
getCaClassFromParams({
caClass: { dimensions: 2, states: 2, neighborhoodId: 'moore' }
})
).toEqual({ dimensions: 2, states: 2, neighborhoodId: 'moore' })
})
it('matches runtimes by dimensions and states without requiring neighbourhood', () => {
expect(
supportsCaClass(
{ supported_classes: [{ dimensions: 2, states: 2 }] },
{ dimensions: 2, states: 2, neighborhoodId: 'von-neumann' }
)
).toBe(true)
expect(
supportsCaClass(
{ supported_classes: [{ dimensions: 3, states: 2 }] },
{ dimensions: 2, states: 2 }
)
).toBe(false)
})
it('honors engine neighbourhood metadata when supplied', () => {
expect(
supportsCaClass(
{ supported_classes: [{ dimensions: 2, states: 2, neighborhoodId: 'moore' }] },
{ dimensions: 2, states: 2, neighborhoodId: 'moore' }
)
).toBe(true)
expect(
supportsCaClass(
{ supported_classes: [{ dimensions: 2, states: 2, neighborhoodId: 'moore' }] },
{ dimensions: 2, states: 2, neighborhoodId: 'von-neumann' }
)
).toBe(false)
})
it('defaults boundary handling to wrap and keeps legacy wrap=false as fixed', () => {
expect(getBoundaryCondition({ simulation: { grid: {} } })).toBe('wrap')
expect(getBoundaryCondition({ simulation: { grid: { wrap: false } } })).toBe('fixed')
expect(getBoundaryCondition({ simulation: { grid: { boundary: 'mirror' } } })).toBe('mirror')
})
it('resolves wrap, mirror, and fixed boundary indexes', () => {
expect(resolveBoundaryIndex(-1, 5, 'wrap')).toBe(4)
expect(resolveBoundaryIndex(-1, 5, 'mirror')).toBe(1)
expect(resolveBoundaryIndex(-1, 5, 'fixed')).toBeNull()
})
it('resolves grid sizes for 2D and 3D classes', () => {
expect(numericGridSize({ size: [7, 8, 9] }, 2)).toEqual([7, 8, 1])
expect(numericGridSize({ size: [7, 8, 9] }, 3)).toEqual([7, 8, 9])
})
it('rejects invalid engine output shapes', () => {
const input: Cells = [
[false, true],
[true, false]
]
expect(() => assertSameCellsShape(input, [[true]], 'test output')).toThrow('preserve the input grid dimensions')
expect(() => assertSameCellsShape(input, [[true], [false, true]], 'test output')).toThrow('rectangular')
})
})
+548
View File
@@ -0,0 +1,548 @@
import request from 'supertest'
import { describe, expect, it } from 'vitest'
import { createCaStudioApi } from '../src/caStudioApi.js'
import { createTestDb } from './testDb.js'
describe('CA Studio API', () => {
it('lists preset nodes whose resolved settings use an engine', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const engine = await request(app)
.post('/api/ca/engines')
.send({
slug: 'usage-engine',
name: 'Usage Engine',
engineKind: 'usage-engine-kind',
supportedClasses: [{ dimensions: 2, states: 2 }]
})
.expect(201)
.then((response) => response.body)
const tree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'usage-tree', name: 'Usage Tree' })
.expect(201)
.then((response) => response.body)
const root = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: { simulation: { engineId: 'usage-engine-kind' } }
})
.expect(201)
.then((response) => response.body)
const inherited = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
parentId: root.id,
slug: 'inherited',
name: 'Inherited',
kind: 'shot',
params: {}
})
.expect(201)
.then((response) => response.body)
await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
parentId: root.id,
slug: 'unset',
name: 'Unset',
kind: 'shot',
params: { simulation: { engineId: null } }
})
.expect(201)
await request(app)
.get(`/api/ca/engines/${engine.id}/usage`)
.expect(200)
.expect((response) => {
expect(response.body).toEqual([
{
tree_id: tree.id,
tree_name: 'Usage Tree',
node_id: inherited.id,
node_name: 'Inherited',
node_kind: 'shot'
},
{
tree_id: tree.id,
tree_name: 'Usage Tree',
node_id: root.id,
node_name: 'Root',
node_kind: 'preset_root'
}
])
})
})
it('creates an empty slide and assigns a CA preset later', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const tree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'deferred-slide-tree', name: 'Deferred Slide Tree' })
.expect(201)
.then((response) => response.body)
const preset = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
slug: 'deferred-preset',
name: 'Deferred Preset',
kind: 'shot',
params: { simulation: { ruleId: 'B3/S23' } }
})
.expect(201)
.then((response) => response.body)
const deck = await request(app)
.post('/api/ca/decks')
.send({ slug: 'deferred-slide-deck', title: 'Deferred Slide Deck' })
.expect(201)
.then((response) => response.body)
const scene = await request(app)
.post(`/api/ca/decks/${deck.id}/scenes`)
.send({
orderIndex: 1,
title: 'Empty Slide',
presetTreeId: null,
presetNodeId: null,
params: { caption: 'Choose a CA later' }
})
.expect(201)
.then((response) => response.body)
await request(app)
.get(`/api/ca/decks/${deck.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.scenes[0]).toMatchObject({
scene: {
preset_tree_id: null,
preset_node_id: null
},
ancestry: [],
params: { caption: 'Choose a CA later' }
})
})
await request(app)
.patch(`/api/ca/scenes/${scene.id}`)
.send({ presetTreeId: tree.id, presetNodeId: preset.id })
.expect(200)
await request(app)
.get(`/api/ca/scenes/${scene.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.params).toMatchObject({
simulation: { ruleId: 'B3/S23' },
caption: 'Choose a CA later'
})
})
})
it('lists preset libraries with node counts and deletes empty libraries', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const emptyTree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'empty-library', name: 'Empty Library' })
.expect(201)
const populatedTree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'populated-library', name: 'Populated Library' })
.expect(201)
await request(app)
.post(`/api/ca/preset-trees/${populatedTree.body.id}/nodes`)
.send({ slug: 'root', name: 'Root', kind: 'preset_root' })
.expect(201)
await request(app)
.get('/api/ca/preset-trees')
.expect(200)
.expect((response) => {
const counts = Object.fromEntries(
response.body.map((tree: { slug: string; node_count: number }) => [tree.slug, tree.node_count])
)
expect(counts).toMatchObject({
'empty-library': 0,
'populated-library': 1
})
})
await request(app).delete(`/api/ca/preset-trees/${emptyTree.body.id}`).expect(204)
await request(app).delete(`/api/ca/preset-trees/${emptyTree.body.id}`).expect(404)
})
it('registers and filters initial condition generators by CA class', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
await request(app)
.post('/api/ca/initial-condition-generators')
.send({
slug: 'life-random-soup-api',
name: 'Life Random Soup',
generatorKind: 'random-soup',
supportedClasses: [{ neighborhoodId: 'moore', dimensions: 2, states: 2 }],
defaultParams: { density: 0.33 }
})
.expect(201)
await request(app)
.post('/api/ca/initial-condition-generators')
.send({
slug: 'lattice-gas-api',
name: 'Lattice Gas Seed',
generatorKind: 'lattice-gas-random',
supportedClasses: [{ neighborhoodId: 'von-neumann', dimensions: 2, states: 7 }]
})
.expect(201)
await request(app)
.get('/api/ca/initial-condition-generators?neighborhoodId=moore&dimensions=2&states=2')
.expect(200)
.expect((response) => {
expect(response.body.map((generator: { slug: string }) => generator.slug)).toEqual([
'life-random-soup-api'
])
})
})
it('creates a tree, nodes, deck, scene, and returns resolved scene config', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const treeResponse = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'api-tree', name: 'API Tree' })
.expect(201)
const treeId = treeResponse.body.id as string
const rootResponse = await request(app)
.post(`/api/ca/preset-trees/${treeId}/nodes`)
.send({
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: { simulation: { ruleId: 'life-3d' } }
})
.expect(201)
const sceneBaseResponse = await request(app)
.post(`/api/ca/preset-trees/${treeId}/nodes`)
.send({
parentId: rootResponse.body.id,
slug: 'glider',
name: 'Glider',
kind: 'shot',
params: { simulation: { initialCondition: { patternId: 'glider' } } }
})
.expect(201)
await request(app)
.get(`/api/ca/preset-trees/${treeId}/nodes/${sceneBaseResponse.body.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.params.simulation).toEqual({
ruleId: 'life-3d',
initialCondition: { patternId: 'glider' }
})
})
const deckResponse = await request(app)
.post('/api/ca/decks')
.send({ slug: 'api-deck', title: 'API Deck' })
.expect(201)
await request(app)
.get(`/api/ca/preset-trees/${treeId}/node-usage`)
.expect(200)
.expect([])
const sceneResponse = await request(app)
.post(`/api/ca/decks/${deckResponse.body.id}/scenes`)
.send({
orderIndex: 1,
title: 'Glider Recording',
presetTreeId: treeId,
presetNodeId: sceneBaseResponse.body.id,
applyMode: 'reinitialize',
params: { camera: { mode: '2d' } }
})
.expect(201)
await request(app)
.get(`/api/ca/preset-trees/${treeId}/node-usage`)
.expect(200)
.expect([{ node_id: sceneBaseResponse.body.id, scene_count: 1 }])
await request(app)
.get(`/api/ca/scenes/${sceneResponse.body.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.params).toEqual({
simulation: {
ruleId: 'life-3d',
initialCondition: { patternId: 'glider' }
},
camera: { mode: '2d' }
})
})
})
it('supports editor CRUD requests for decks, scenes, and preset-driven reloads', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const tree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'editor-tree', name: 'Editor Tree' })
.expect(201)
.then((response) => response.body)
const root = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
simulation: { ruleId: 'life-3d', grid: { size: [32, 32, 1] } },
renderer: { id: 'classic-voxels' }
}
})
.expect(201)
.then((response) => response.body)
const shot = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
parentId: root.id,
slug: 'blinker',
name: 'Blinker',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'blinker' } },
camera: { mode: '2d', zoom: 1 }
}
})
.expect(201)
.then((response) => response.body)
const deck = await request(app)
.post('/api/ca/decks')
.send({ slug: 'editor-deck', title: 'Editor Deck' })
.expect(201)
.then((response) => response.body)
await request(app)
.patch(`/api/ca/decks/${deck.id}`)
.send({ title: 'Updated Editor Deck', params: { recording: { fps: 60 } } })
.expect(200)
.expect((response) => {
expect(response.body.title).toBe('Updated Editor Deck')
expect(response.body.params).toEqual({ recording: { fps: 60 } })
})
await request(app)
.get('/api/ca/decks')
.expect(200)
.expect((response) => {
expect(response.body.map((item: { slug: string }) => item.slug)).toEqual(['editor-deck'])
})
const scene = await request(app)
.post(`/api/ca/decks/${deck.id}/scenes`)
.send({
orderIndex: 1,
title: 'Blinker Recording',
presetTreeId: tree.id,
presetNodeId: shot.id,
params: { camera: { zoom: 1.4 } }
})
.expect(201)
.then((response) => response.body)
await request(app)
.get(`/api/ca/scenes/${scene.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.params).toEqual({
simulation: {
ruleId: 'life-3d',
grid: { size: [32, 32, 1] },
initialCondition: { patternId: 'blinker' }
},
renderer: { id: 'classic-voxels' },
camera: { mode: '2d', zoom: 1.4 }
})
})
await request(app)
.patch(`/api/ca/preset-trees/${tree.id}/nodes/${shot.id}`)
.send({
params: {
simulation: { initialCondition: { patternId: 'glider' } },
camera: { mode: '2d', zoom: 0.75 }
}
})
.expect(200)
await request(app)
.get(`/api/ca/scenes/${scene.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.params.camera).toEqual({ mode: '2d', zoom: 1.4 })
expect(response.body.params.simulation.initialCondition).toEqual({ patternId: 'glider' })
})
await request(app)
.patch(`/api/ca/scenes/${scene.id}`)
.send({
orderIndex: 2,
title: 'Glider Patch',
applyMode: 'patch_existing',
params: { simulation: { speed: 1.5 }, overlays: ['clean-recording'] }
})
.expect(200)
.expect((response) => {
expect(response.body).toMatchObject({
order_index: 2,
title: 'Glider Patch',
apply_mode: 'patch_existing',
requires_previous_scene: true
})
})
await request(app)
.get(`/api/ca/decks/${deck.id}/scenes`)
.expect(200)
.expect((response) => {
expect(response.body.map((item: { title: string }) => item.title)).toEqual(['Glider Patch'])
})
await request(app).delete(`/api/ca/scenes/${scene.id}`).expect(204)
await request(app).get(`/api/ca/scenes/${scene.id}`).expect(404)
await request(app).get(`/api/ca/preset-trees/${tree.id}/nodes/${shot.id}`).expect(200)
await request(app).delete(`/api/ca/decks/${deck.id}`).expect(204)
await request(app).get(`/api/ca/decks/${deck.id}`).expect(404)
})
it('returns a resolved deck payload for viewer playback', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const tree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'viewer-tree', name: 'Viewer Tree' })
.expect(201)
.then((response) => response.body)
const root = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
simulation: { ruleId: 'life-3d' },
renderer: { id: 'classic-voxels' }
}
})
.expect(201)
.then((response) => response.body)
const singleCell = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
parentId: root.id,
slug: 'single-cell',
name: 'Single Cell',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'single-cell' } }
}
})
.expect(201)
.then((response) => response.body)
const blinker = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
parentId: root.id,
slug: 'blinker',
name: 'Blinker',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'blinker' } }
}
})
.expect(201)
.then((response) => response.body)
const deck = await request(app)
.post('/api/ca/decks')
.send({ slug: 'viewer-deck', title: 'Viewer Deck' })
.expect(201)
.then((response) => response.body)
await request(app)
.post(`/api/ca/decks/${deck.id}/scenes`)
.send({
orderIndex: 2,
title: 'Blinker',
presetTreeId: tree.id,
presetNodeId: blinker.id,
applyMode: 'patch_existing',
params: { camera: { mode: '2d', zoom: 1.1 } }
})
.expect(201)
await request(app)
.post(`/api/ca/decks/${deck.id}/scenes`)
.send({
orderIndex: 1,
title: 'Single Cell',
presetTreeId: tree.id,
presetNodeId: singleCell.id,
params: { camera: { mode: '2d', zoom: 1.5 } }
})
.expect(201)
await request(app)
.get(`/api/ca/decks/${deck.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.deck.title).toBe('Viewer Deck')
expect(response.body.scenes.map((entry: { scene: { title: string } }) => entry.scene.title)).toEqual([
'Single Cell',
'Blinker'
])
expect(response.body.scenes[0].params).toMatchObject({
simulation: {
ruleId: 'life-3d',
initialCondition: { patternId: 'single-cell' }
},
renderer: { id: 'classic-voxels' },
camera: { mode: '2d', zoom: 1.5 }
})
expect(response.body.scenes[1].scene.requires_previous_scene).toBe(true)
expect(response.body.scenes[1].params.camera).toEqual({ mode: '2d', zoom: 1.1 })
})
})
})
+483
View File
@@ -0,0 +1,483 @@
import { describe, expect, it } from 'vitest'
import {
createDeck,
createInitialConditionGenerator,
createPresetNode,
createPresetTree,
createScene,
deleteDeck,
deletePresetNode,
deleteScene,
getDeck,
getScene,
listInitialConditionGenerators,
listDecks,
listPresetNodes,
listScenes,
resolveDeck,
resolvePresetNode,
resolveScene,
updateDeck,
updatePresetNode,
updateScene
} from '../src/caStudioRepository.js'
import { createTestDb } from './testDb.js'
describe('CA Studio repository', () => {
it('creates preset nodes, resolves inherited params, and treats null as unset', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'ca-series', name: 'CA Series' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
caClass: { neighborhoodId: 'moore', dimensions: 2, states: 2 },
simulation: { grid: { size: [32, 32, 1], wrap: true }, ruleId: 'life-3d' },
renderer: { id: 'voxel-instanced', quality: 'high' },
overlays: ['generation-counter']
}
})
const chapter = await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'game-of-life',
name: 'Game of Life',
kind: 'chapter',
params: {
simulation: { ruleId: 'game-of-life' },
overlays: ['minimal-title']
}
})
const shot = await createPresetNode(db, {
treeId: tree.id,
parentId: chapter.id,
slug: 'blinker',
name: 'Blinker',
kind: 'shot',
params: {
simulation: { grid: { wrap: null }, initialCondition: { patternId: 'blinker' } },
camera: { mode: '2d' }
}
})
const resolved = await resolvePresetNode(db, tree.id, shot.id)
expect(resolved?.ancestry.map((node) => node.slug)).toEqual(['root', 'game-of-life', 'blinker'])
expect(resolved?.params).toEqual({
simulation: {
grid: { size: [32, 32, 1] },
ruleId: 'game-of-life',
initialCondition: { patternId: 'blinker' }
},
caClass: { neighborhoodId: 'moore', dimensions: 2, states: 2 },
renderer: { id: 'voxel-instanced', quality: 'high' },
overlays: ['minimal-title'],
camera: { mode: '2d' }
})
})
it('registers initial condition generators and filters them by CA class', async () => {
const db = await createTestDb()
await createInitialConditionGenerator(db, {
slug: 'life-random-soup',
name: 'Life Random Soup',
generatorKind: 'random-soup',
supportedClasses: [{ neighborhoodId: 'moore', dimensions: 2, states: 2 }],
defaultParams: { density: 0.28 }
})
await createInitialConditionGenerator(db, {
slug: 'von-neumann-traffic',
name: 'Traffic Seed',
generatorKind: 'traffic-lanes',
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, {
caClass: { neighborhoodId: 'moore', dimensions: 2, states: 2 }
})).map((generator) => generator.slug)
).toEqual(['life-random-soup'])
})
it('rejects cross-tree parents and duplicate sibling slugs', async () => {
const db = await createTestDb()
const firstTree = await createPresetTree(db, { slug: 'first', name: 'First' })
const secondTree = await createPresetTree(db, { slug: 'second', name: 'Second' })
const root = await createPresetNode(db, {
treeId: firstTree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root'
})
await expect(
createPresetNode(db, {
treeId: secondTree.id,
parentId: root.id,
slug: 'bad-parent',
name: 'Bad Parent'
})
).rejects.toThrow()
await createPresetNode(db, {
treeId: firstTree.id,
parentId: root.id,
slug: 'child',
name: 'Child A'
})
await expect(
createPresetNode(db, {
treeId: firstTree.id,
parentId: root.id,
slug: 'child',
name: 'Child B'
})
).rejects.toThrow()
})
it('prevents parent cycles', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'cycles', name: 'Cycles' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root'
})
const child = await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'child',
name: 'Child'
})
await expect(updatePresetNode(db, tree.id, root.id, { parentId: child.id })).rejects.toThrow(
/cycle/i
)
})
it('cascades child preset nodes when deleting a parent', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'delete-tree', name: 'Delete Tree' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root'
})
await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'child',
name: 'Child'
})
expect(await deletePresetNode(db, tree.id, root.id)).toBe(true)
expect(await listPresetNodes(db, tree.id)).toEqual([])
})
it('creates ordered scenes and resolves scene params over preset params', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'deck-tree', name: 'Deck Tree' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
simulation: { ruleId: 'life-3d', grid: { size: [16, 16, 16] } },
renderer: { id: 'classic-voxels' }
}
})
const deck = await createDeck(db, { slug: 'ca-001', title: 'CA 001' })
const intro = await createScene(db, {
deckId: deck.id,
orderIndex: 1,
title: 'Intro',
presetTreeId: tree.id,
presetNodeId: root.id,
params: { camera: { mode: '2d' } }
})
await createScene(db, {
deckId: deck.id,
orderIndex: 2,
title: 'Patch',
presetTreeId: tree.id,
presetNodeId: root.id,
applyMode: 'patch_existing',
params: { simulation: { speed: 2 } }
})
await expect(
createScene(db, {
deckId: deck.id,
orderIndex: 1,
title: 'Duplicate Order',
presetTreeId: tree.id,
presetNodeId: root.id
})
).rejects.toThrow()
expect((await listScenes(db, deck.id)).map((scene) => scene.title)).toEqual(['Intro', 'Patch'])
expect((await listScenes(db, deck.id))[1].requires_previous_scene).toBe(true)
expect(await resolveScene(db, intro.id)).toMatchObject({
params: {
simulation: { ruleId: 'life-3d', grid: { size: [16, 16, 16] } },
renderer: { id: 'classic-voxels' },
camera: { mode: '2d' }
}
})
})
it('loads scenes through preset references and reflects preset updates on the next resolve', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'studio-tree', name: 'Studio Tree' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
simulation: { grid: { size: [24, 24, 1], wrap: true }, ruleId: 'life-3d' },
renderer: { id: 'classic-voxels', quality: 'medium' },
overlays: ['title', 'generation-counter']
}
})
const gliderPreset = await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'glider',
name: 'Glider',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'glider' }, speed: 1 },
camera: { mode: '2d', zoom: 1 }
}
})
const deck = await createDeck(db, { slug: 'studio-deck', title: 'Studio Deck' })
const scene = await createScene(db, {
deckId: deck.id,
orderIndex: 1,
title: 'Glider Shot',
presetTreeId: tree.id,
presetNodeId: gliderPreset.id,
params: {
camera: { zoom: 1.25 },
renderer: { quality: null }
}
})
expect(await resolveScene(db, scene.id)).toMatchObject({
params: {
simulation: {
grid: { size: [24, 24, 1], wrap: true },
ruleId: 'life-3d',
initialCondition: { patternId: 'glider' },
speed: 1
},
renderer: { id: 'classic-voxels' },
overlays: ['title', 'generation-counter'],
camera: { mode: '2d', zoom: 1.25 }
}
})
await updatePresetNode(db, tree.id, gliderPreset.id, {
params: {
simulation: { initialCondition: { patternId: 'lightweight-spaceship' }, speed: 1.75 },
camera: { mode: '2d', zoom: 0.85 }
}
})
expect(await resolveScene(db, scene.id)).toMatchObject({
params: {
simulation: {
grid: { size: [24, 24, 1], wrap: true },
ruleId: 'life-3d',
initialCondition: { patternId: 'lightweight-spaceship' },
speed: 1.75
},
renderer: { id: 'classic-voxels' },
camera: { mode: '2d', zoom: 1.25 }
}
})
})
it('updates deck and scene records used by the editor CRUD flow', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'crud-tree', name: 'CRUD Tree' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: { simulation: { ruleId: 'life-3d' } }
})
const deck = await createDeck(db, {
slug: 'crud-deck',
title: 'CRUD Deck',
params: { recording: { fps: 30 } }
})
const scene = await createScene(db, {
deckId: deck.id,
orderIndex: 1,
title: 'Original Scene',
presetTreeId: tree.id,
presetNodeId: root.id,
params: { camera: { mode: '3d' } }
})
const updatedDeck = await updateDeck(db, deck.id, {
title: 'Updated Deck',
params: { recording: { fps: 60, resolution: [1920, 1080] } }
})
expect(updatedDeck).toMatchObject({
title: 'Updated Deck',
params: { recording: { fps: 60, resolution: [1920, 1080] } }
})
expect(await getDeck(db, deck.id)).toMatchObject({ title: 'Updated Deck' })
expect((await listDecks(db)).map((item) => item.slug)).toEqual(['crud-deck'])
const updatedScene = await updateScene(db, scene.id, {
orderIndex: 2,
title: 'Updated Scene',
applyMode: 'patch_existing',
params: { camera: { mode: '2d' }, simulation: { speed: 2 } },
transition: { type: 'cut' }
})
expect(updatedScene).toMatchObject({
order_index: 2,
title: 'Updated Scene',
apply_mode: 'patch_existing',
requires_previous_scene: true,
params: { camera: { mode: '2d' }, simulation: { speed: 2 } },
transition: { type: 'cut' }
})
expect(await getScene(db, scene.id)).toMatchObject({ title: 'Updated Scene' })
expect(await resolveScene(db, scene.id)).toMatchObject({
params: {
simulation: { ruleId: 'life-3d', speed: 2 },
camera: { mode: '2d' }
}
})
expect(await deleteScene(db, scene.id)).toBe(true)
expect(await listScenes(db, deck.id)).toEqual([])
expect(await deleteDeck(db, deck.id)).toBe(true)
expect(await getDeck(db, deck.id)).toBeNull()
})
it('resolves a full deck as ordered playback scenes', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'playback-tree', name: 'Playback Tree' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
simulation: { ruleId: 'life-3d', grid: { size: [40, 40, 1] } },
renderer: { id: 'classic-voxels' },
recording: { fps: 60 }
}
})
const blinker = await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'blinker',
name: 'Blinker',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'blinker' } },
camera: { mode: '2d', zoom: 1 }
}
})
const glider = await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'glider',
name: 'Glider',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'glider' } },
camera: { mode: '2d', zoom: 0.9 }
}
})
const deck = await createDeck(db, { slug: 'playback-deck', title: 'Playback Deck' })
await createScene(db, {
deckId: deck.id,
orderIndex: 2,
title: 'Glider Scene',
presetTreeId: tree.id,
presetNodeId: glider.id,
applyMode: 'patch_existing',
params: { camera: { zoom: 1.2 } }
})
await createScene(db, {
deckId: deck.id,
orderIndex: 1,
title: 'Blinker Scene',
presetTreeId: tree.id,
presetNodeId: blinker.id
})
expect(await resolveDeck(db, deck.id)).toMatchObject({
deck: { title: 'Playback Deck' },
scenes: [
{
scene: { order_index: 1, title: 'Blinker Scene' },
params: {
simulation: {
ruleId: 'life-3d',
grid: { size: [40, 40, 1] },
initialCondition: { patternId: 'blinker' }
},
renderer: { id: 'classic-voxels' },
recording: { fps: 60 },
camera: { mode: '2d', zoom: 1 }
}
},
{
scene: {
order_index: 2,
title: 'Glider Scene',
apply_mode: 'patch_existing',
requires_previous_scene: true
},
params: {
simulation: {
ruleId: 'life-3d',
grid: { size: [40, 40, 1] },
initialCondition: { patternId: 'glider' }
},
renderer: { id: 'classic-voxels' },
recording: { fps: 60 },
camera: { mode: '2d', zoom: 1.2 }
}
}
]
})
await updatePresetNode(db, tree.id, root.id, {
params: {
simulation: { ruleId: 'life-3d', grid: { size: [48, 48, 1] } },
renderer: { id: 'neon-voxels' },
recording: { fps: 30 }
}
})
const resolved = await resolveDeck(db, deck.id)
expect(resolved?.scenes.map((entry) => entry.params.renderer)).toEqual([
{ id: 'neon-voxels' },
{ id: 'neon-voxels' }
])
expect(resolved?.scenes.map((entry) => entry.params.recording)).toEqual([{ fps: 30 }, { fps: 30 }])
expect(resolved?.scenes[1].params.camera).toEqual({ mode: '2d', zoom: 1.2 })
})
})
+45
View File
@@ -0,0 +1,45 @@
import { readdir, readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PGlite } from '@electric-sql/pglite'
import { Pool } from 'pg'
const __dirname = dirname(fileURLToPath(import.meta.url))
const migrationsDir = resolve(__dirname, '../src/migrations')
function createSchemaName() {
return `ca_test_${Date.now()}_${Math.random().toString(16).slice(2)}`
}
export async function createTestDb() {
const postgresUrl = process.env.CA_STUDIO_TEST_DATABASE_URL
const migrationFiles = (await readdir(migrationsDir))
.filter((file) => file.endsWith('.sql'))
.sort()
const migrationSql = await Promise.all(
migrationFiles.map(async (file) => readFile(resolve(migrationsDir, file), 'utf8'))
)
if (postgresUrl) {
const schema = createSchemaName()
const bootstrapPool = new Pool({ connectionString: postgresUrl })
await bootstrapPool.query(`CREATE SCHEMA "${schema}"`)
await bootstrapPool.end()
const pool = new Pool({
connectionString: postgresUrl,
options: `-c search_path=${schema},public`,
allowExitOnIdle: true
})
for (const sql of migrationSql) {
await pool.query(sql)
}
return pool
}
const db = new PGlite()
for (const sql of migrationSql) {
await db.exec(sql)
}
return db
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/migrations/**/*.sql"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "admin/src/**/*.ts", "admin/src/**/*.tsx"]
}