Adding the lab project
This commit is contained in:
@@ -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>
|
||||
@@ -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 }
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
Vendored
+235
@@ -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
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+606
@@ -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
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+12
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
+13
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Generated
+4426
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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}`)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user