Adding the lab project
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
# Deploy CA Lab Studio
|
||||||
|
|
||||||
|
Deploy `glitch_voxel_automata_lab`, not `glitch_voxel_automata`.
|
||||||
|
|
||||||
|
This app is the CA Lab Studio service:
|
||||||
|
|
||||||
|
- Express API at `/api/ca/*`
|
||||||
|
- React admin app at `/admin`
|
||||||
|
- React viewer/recorder routes at `/view/*`
|
||||||
|
- Postgres database via `DATABASE_URL`
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nvm use 20
|
||||||
|
npm --prefix backend ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The build creates:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
backend/dist/server.js
|
||||||
|
backend/public/admin/index.html
|
||||||
|
backend/public/admin/assets/*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://... PORT=3100 npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Run migrations before starting or during release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://... npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t ca-lab-studio .
|
||||||
|
docker run --rm -p 3100:3100 \
|
||||||
|
-e DATABASE_URL=postgres://... \
|
||||||
|
ca-lab-studio
|
||||||
|
```
|
||||||
|
|
||||||
|
The container serves the app at:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
http://localhost:3100/admin
|
||||||
|
http://localhost:3100/view/decks/:deckId
|
||||||
|
```
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
FROM node:20-bookworm-slim AS build
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
COPY backend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY backend ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim AS runtime
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
COPY backend/package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY --from=build /app/backend/dist ./dist
|
||||||
|
COPY --from=build /app/backend/public ./public
|
||||||
|
COPY backend/scripts ./scripts
|
||||||
|
COPY backend/src/migrations ./src/migrations
|
||||||
|
|
||||||
|
EXPOSE 3100
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# CA Lab Studio
|
||||||
|
|
||||||
|
This folder contains the CA Lab Studio application: an Express API, Postgres-backed preset/deck data model, React admin interface, and viewer/recorder routes.
|
||||||
|
|
||||||
|
The original embeddable GlitchComponent has been moved to the sibling folder:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/Users/jenstandstad/Projects/glitch-components/glitch_voxel_automata
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
Start the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Run migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal, start the admin UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run admin:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
http://localhost:5174/admin/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Note
|
||||||
|
|
||||||
|
Deploy this app, not the sibling GlitchComponent package. See [DEPLOY.md](/Users/jenstandstad/Projects/glitch-components/glitch_voxel_automata_lab/DEPLOY.md).
|
||||||
|
|
||||||
|
The production build compiles the Express API to `backend/dist` and emits the admin/viewer app to `backend/public/admin`.
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# deploy.sh - Deploy CA Lab Studio to its own stack on the VPS.
|
||||||
|
#
|
||||||
|
# This stack is independent of gnommoweb. It joins the shared `gnommo` external
|
||||||
|
# network (owned by gu_common) so it can reach gnommo-db and be reached by the
|
||||||
|
# shared nginx.
|
||||||
|
#
|
||||||
|
# Prerequisites on the server:
|
||||||
|
# - gu_common stack is running (provides gnommo-db, nginx, and the gnommo network)
|
||||||
|
# - ${REMOTE_DIR}/.env.prod exists with POSTGRES_USER, POSTGRES_PASSWORD
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SERVER="${DEPLOY_SERVER:-root@76.13.144.52}"
|
||||||
|
REMOTE_DIR="${DEPLOY_DIR:-/opt/voxel-automata-lab}"
|
||||||
|
COMPOSE="docker compose -f ${REMOTE_DIR}/docker-compose.prod.yml --env-file ${REMOTE_DIR}/.env.prod"
|
||||||
|
|
||||||
|
# Refuse to run on the production server itself.
|
||||||
|
TARGET_HOST=$(echo ${SERVER} | sed 's/.*@//')
|
||||||
|
OWN_IP=$(curl -sf --max-time 3 ifconfig.me 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$OWN_IP" = "$TARGET_HOST" ]; then
|
||||||
|
echo "Error: deploy.sh must be run from your local machine, not the server."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
echo "==> Deploying CA Lab Studio to ${SERVER}:${REMOTE_DIR}"
|
||||||
|
|
||||||
|
# Ensure shared cross-project network exists.
|
||||||
|
echo "==> Ensuring gnommo network exists..."
|
||||||
|
ssh ${SERVER} "docker network create gnommo 2>/dev/null || true"
|
||||||
|
|
||||||
|
# Ensure the remote dir exists.
|
||||||
|
ssh ${SERVER} "mkdir -p ${REMOTE_DIR}"
|
||||||
|
|
||||||
|
# Sync source files. .env.prod is never overwritten by deploy.
|
||||||
|
echo "==> Syncing source..."
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude 'dist' \
|
||||||
|
--exclude 'dist-standalone' \
|
||||||
|
--exclude '.DS_Store' \
|
||||||
|
--exclude '*.log' \
|
||||||
|
--exclude '.env' \
|
||||||
|
--exclude '.env.local' \
|
||||||
|
--exclude '.env.prod' \
|
||||||
|
./ ${SERVER}:${REMOTE_DIR}/
|
||||||
|
|
||||||
|
# Sanity check: .env.prod must exist on the server.
|
||||||
|
if ! ssh ${SERVER} "test -f ${REMOTE_DIR}/.env.prod"; then
|
||||||
|
echo "Error: ${REMOTE_DIR}/.env.prod not found on server."
|
||||||
|
echo " Create it with POSTGRES_USER, POSTGRES_PASSWORD (matching gu_common),"
|
||||||
|
echo " and optionally LAB_DB (defaults to ca_studio)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rebuild the lab image. CACHEBUST keeps the Docker layer cache honest when
|
||||||
|
# only source files change.
|
||||||
|
echo "==> Building lab image..."
|
||||||
|
ssh ${SERVER} "CACHEBUST=\$(date +%s) && $COMPOSE build --build-arg CACHEBUST=\$CACHEBUST"
|
||||||
|
|
||||||
|
# Start lab-db-init (idempotent — creates ca_studio + pgcrypto if missing),
|
||||||
|
# then bring up the lab service. depends_on handles ordering.
|
||||||
|
echo "==> Starting services..."
|
||||||
|
ssh ${SERVER} "$COMPOSE up -d"
|
||||||
|
|
||||||
|
# Wait for the lab to respond. It runs migrations on startup, so first
|
||||||
|
# request may take a few seconds.
|
||||||
|
echo "==> Waiting for lab to be ready..."
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
if ssh ${SERVER} "$COMPOSE exec -T lab node -e 'process.exit(0)'" 2>/dev/null; then
|
||||||
|
echo " Lab is up."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " Not ready yet, waiting... ($i/24)"
|
||||||
|
sleep 5
|
||||||
|
if [ $i -eq 24 ]; then
|
||||||
|
echo "ERROR: Lab did not start within 2 minutes."
|
||||||
|
echo " Check logs: ssh ${SERVER} '$COMPOSE logs --tail=100 lab'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> Done! Lab should be live at https://lab.glitch.university shortly."
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Production compose for CA Lab Studio.
|
||||||
|
# Usage: docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
|
||||||
|
#
|
||||||
|
# This stack is independent of gnommoweb. It joins the shared `gnommo`
|
||||||
|
# external network to reach gnommo-db (Postgres) and to be reachable by the
|
||||||
|
# shared nginx (gu_common) which proxies lab.${DOMAIN} → gnommo-lab:3100.
|
||||||
|
#
|
||||||
|
# Required env vars (load via .env.prod on the server):
|
||||||
|
# POSTGRES_USER, POSTGRES_PASSWORD — credentials for gnommo-db (shared)
|
||||||
|
# LAB_DB (optional) — database name within gnommo-db (default: ca_studio)
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# Idempotently ensures the ca_studio database + pgcrypto extension exist on
|
||||||
|
# the shared gnommo-db Postgres before the lab service starts.
|
||||||
|
lab-db-init:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
PGUSER: ${POSTGRES_USER}
|
||||||
|
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
PGHOST: gnommo-db
|
||||||
|
PGPORT: "5432"
|
||||||
|
LAB_DB: ${LAB_DB:-ca_studio}
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
until pg_isready -h gnommo-db -U $${PGUSER}; do echo 'Waiting for gnommo-db...'; sleep 2; done;
|
||||||
|
psql -d postgres -tAc \"SELECT 1 FROM pg_database WHERE datname='$${LAB_DB}'\" | grep -q 1 ||
|
||||||
|
psql -d postgres -c \"CREATE DATABASE $${LAB_DB};\";
|
||||||
|
psql -d $${LAB_DB} -c 'CREATE EXTENSION IF NOT EXISTS pgcrypto;';
|
||||||
|
echo 'Lab DB: ready.'
|
||||||
|
"
|
||||||
|
networks:
|
||||||
|
- gnommo
|
||||||
|
|
||||||
|
lab:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: gnommo-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "3100"
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${LAB_DB:-ca_studio}
|
||||||
|
# Run pending migrations before starting the server.
|
||||||
|
# The lab's migrate script is idempotent (CREATE TABLE IF NOT EXISTS).
|
||||||
|
command: sh -c "node scripts/migrate.mjs && node dist/server.js"
|
||||||
|
expose:
|
||||||
|
- "3100"
|
||||||
|
depends_on:
|
||||||
|
lab-db-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- gnommo
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default: {}
|
||||||
|
gnommo:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Dev compose — provides a local Postgres for `npm run dev`.
|
||||||
|
# The lab itself runs natively via `npm run dev` for fast iteration.
|
||||||
|
#
|
||||||
|
# Replaces backend/docker-compose.yml at the repo root so dev and prod
|
||||||
|
# compose files live side-by-side. Container name and volume name are kept
|
||||||
|
# the same as the old backend compose so existing local data is preserved.
|
||||||
|
#
|
||||||
|
# Connect from the host with:
|
||||||
|
# DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: ca-studio-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ca_studio
|
||||||
|
POSTGRES_PASSWORD: ca_studio
|
||||||
|
POSTGRES_DB: ca_studio_test
|
||||||
|
ports:
|
||||||
|
- "54329:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ca_studio -d ca_studio_test"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
volumes:
|
||||||
|
- ca-studio-postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ca-studio-postgres-data:
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@glitch-components/voxel-automata-lab-studio",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "CA Lab Studio application for designing and recording cellular automata presentations.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm --prefix backend run dev",
|
||||||
|
"admin:dev": "npm --prefix backend run admin:dev",
|
||||||
|
"build": "npm --prefix backend run build",
|
||||||
|
"migrate": "npm --prefix backend run migrate",
|
||||||
|
"start": "npm --prefix backend run start",
|
||||||
|
"test": "npm --prefix backend run test",
|
||||||
|
"test:postgres": "npm --prefix backend run test:postgres",
|
||||||
|
"typecheck": "npm --prefix backend run typecheck"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user