Adding the lab project

This commit is contained in:
2026-07-01 12:10:12 +02:00
commit 48ce52b52c
56 changed files with 22289 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CA Studio Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+538
View File
@@ -0,0 +1,538 @@
import React from 'react'
import { getCaClassFromParams, supportsCaClass } from './caRuntime.js'
import type { CaClass, Cells, SceneParams } from './types.js'
import { Voxel3DRenderer } from './voxel3d/Voxel3DRenderer.js'
interface CaRendererProps {
caption: string
cells: Cells
settings: SceneParams
onCellsChange: (cells: Cells) => void
}
export interface CaRendererRuntime {
id: string
label: string
aliases?: string[]
supportedClasses: CaClass[]
Component: React.ComponentType<CaRendererProps>
}
const rendererRuntimes = new Map<string, CaRendererRuntime>()
export function registerCaRendererRuntime(runtime: CaRendererRuntime, options: { replace?: boolean } = {}) {
if (!runtime.id.trim()) throw new Error('CA renderer runtime id is required')
if (rendererRuntimes.has(runtime.id) && !options.replace) {
throw new Error(`CA renderer runtime already registered: ${runtime.id}`)
}
rendererRuntimes.set(runtime.id, runtime)
}
export function listCaRendererRuntimes() {
return [...rendererRuntimes.values()]
}
export function listCompatibleCaRendererRuntimes(caClass: CaClass | undefined) {
return listCaRendererRuntimes().filter((runtime) => supportsCaClass(runtime, caClass))
}
export function getCaRendererRuntime(rendererId: string) {
return (
rendererRuntimes.get(rendererId) ??
[...rendererRuntimes.values()].find((runtime) => runtime.aliases?.includes(rendererId)) ??
null
)
}
export function CaRenderer({ caption, cells, settings, onCellsChange }: CaRendererProps) {
const rendererId = settings.renderer?.id ?? '2d-canvas'
const runtime = getCaRendererRuntime(rendererId)
const caClass = getCaClassFromParams(settings)
if (runtime && (!caClass || supportsCaClass(runtime, caClass))) {
const RendererComponent = runtime.Component
return <RendererComponent caption={caption} cells={cells} settings={settings} onCellsChange={onCellsChange} />
}
return (
<div className="board-wrap">
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
<div className="renderer-empty">
{runtime ? `Renderer ${rendererId} does not support this CA space` : `Renderer unavailable: ${rendererId}`}
</div>
</div>
)
}
function Voxel3DRendererAdapter({ caption, settings }: CaRendererProps) {
return <Voxel3DRenderer caption={caption} settings={settings} />
}
function rendererNumber(value: unknown, fallback: number, min: number, max: number) {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback
}
function parseElementaryRule(settings: SceneParams) {
const rendererRule = settings.renderer?.elementaryRule
if (typeof rendererRule === 'number') return Math.max(0, Math.min(255, Math.floor(rendererRule)))
const ruleId = settings.simulation?.ruleId ?? ''
const match = /(?:rule[-_\s]*)?(\d{1,3})/i.exec(ruleId)
if (!match) return 110
return Math.max(0, Math.min(255, Number(match[1])))
}
function nextElementaryRow(row: boolean[], rule: number) {
return row.map((_, index) => {
const left = row[(index - 1 + row.length) % row.length] ? 1 : 0
const center = row[index] ? 1 : 0
const right = row[(index + 1) % row.length] ? 1 : 0
const pattern = (left << 2) | (center << 1) | right
return ((rule >> pattern) & 1) === 1
})
}
function firstLiveRowIndex(cells: Cells) {
const index = cells.findIndex((row) => row.some(Boolean))
return index === -1 ? 0 : index
}
function Canvas2DRenderer({
caption,
cells,
onCellsChange
}: {
caption: string
cells: Cells
onCellsChange: (cells: Cells) => void
}) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
const cellsRef = React.useRef(cells)
const paintValueRef = React.useRef<boolean | null>(null)
const [canvasRevision, setCanvasRevision] = React.useState(0)
const width = cells[0]?.length ?? 0
const height = cells.length
React.useEffect(() => {
cellsRef.current = cells
}, [cells])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const observer = new ResizeObserver(() => setCanvasRevision((revision) => revision + 1))
observer.observe(canvas)
return () => observer.disconnect()
}, [])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas || width === 0 || height === 0) return
const context = canvas.getContext('2d')
if (!context) return
const rect = canvas.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
}
context.setTransform(dpr, 0, 0, dpr, 0, 0)
context.clearRect(0, 0, rect.width, rect.height)
context.fillStyle = '#071206'
context.fillRect(0, 0, rect.width, rect.height)
const cellSize = Math.min(rect.width / width, rect.height / height)
const boardWidth = cellSize * width
const boardHeight = cellSize * height
const offsetX = (rect.width - boardWidth) / 2
const offsetY = (rect.height - boardHeight) / 2
const gap = Math.max(1, Math.min(2, cellSize * 0.08))
context.strokeStyle = '#1e3519'
context.lineWidth = 1
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const px = offsetX + x * cellSize + gap / 2
const py = offsetY + y * cellSize + gap / 2
const size = Math.max(1, cellSize - gap)
if (cells[y][x]) {
context.fillStyle = '#70f45f'
context.shadowColor = 'rgba(112, 244, 95, 0.42)'
context.shadowBlur = Math.max(0, cellSize * 0.38)
context.fillRect(px, py, size, size)
context.shadowBlur = 0
context.strokeStyle = '#b7ffab'
} else {
context.fillStyle = '#071206'
context.fillRect(px, py, size, size)
context.strokeStyle = '#1e3519'
}
context.strokeRect(px + 0.5, py + 0.5, Math.max(0, size - 1), Math.max(0, size - 1))
}
}
}, [canvasRevision, cells, height, width])
function cellFromPointer(event: React.PointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current
if (!canvas || width === 0 || height === 0) return null
const rect = canvas.getBoundingClientRect()
const cellSize = Math.min(rect.width / width, rect.height / height)
const boardWidth = cellSize * width
const boardHeight = cellSize * height
const offsetX = (rect.width - boardWidth) / 2
const offsetY = (rect.height - boardHeight) / 2
const x = Math.floor((event.clientX - rect.left - offsetX) / cellSize)
const y = Math.floor((event.clientY - rect.top - offsetY) / cellSize)
if (x < 0 || y < 0 || x >= width || y >= height) return null
return { x, y }
}
function paintCell(x: number, y: number, value: boolean) {
const current = cellsRef.current
if (current[y]?.[x] === value) return
const next = current.map((row, rowIndex) =>
rowIndex === y ? row.map((alive, columnIndex) => (columnIndex === x ? value : alive)) : row
)
cellsRef.current = next
onCellsChange(next)
}
function handlePointerDown(event: React.PointerEvent<HTMLCanvasElement>) {
const cell = cellFromPointer(event)
if (!cell) return
event.currentTarget.setPointerCapture(event.pointerId)
const nextValue = !cellsRef.current[cell.y][cell.x]
paintValueRef.current = nextValue
paintCell(cell.x, cell.y, nextValue)
}
function handlePointerMove(event: React.PointerEvent<HTMLCanvasElement>) {
if (paintValueRef.current === null) return
const cell = cellFromPointer(event)
if (!cell) return
paintCell(cell.x, cell.y, paintValueRef.current)
}
function stopPainting(event: React.PointerEvent<HTMLCanvasElement>) {
paintValueRef.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
return (
<div className="board-wrap">
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
<canvas
ref={canvasRef}
aria-label="Game of Life cell editor"
className="board-canvas"
role="img"
onPointerCancel={stopPainting}
onPointerDown={handlePointerDown}
onPointerLeave={stopPainting}
onPointerMove={handlePointerMove}
onPointerUp={stopPainting}
/>
</div>
)
}
function Elementary1DRenderer({ caption, cells, settings, onCellsChange }: CaRendererProps) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
const cellsRef = React.useRef(cells)
const [canvasRevision, setCanvasRevision] = React.useState(0)
const width = cells[0]?.length ?? 0
const sourceRowIndex = Math.min(cells.length - 1, Math.max(0, firstLiveRowIndex(cells)))
const sourceRow = cells[sourceRowIndex] ?? []
const rule = parseElementaryRule(settings)
const historyRows = rendererNumber(settings.renderer?.historyRows, 96, 8, 512)
React.useEffect(() => {
cellsRef.current = cells
}, [cells])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const observer = new ResizeObserver(() => setCanvasRevision((revision) => revision + 1))
observer.observe(canvas)
return () => observer.disconnect()
}, [])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas || width === 0 || sourceRow.length === 0) return
const context = canvas.getContext('2d')
if (!context) return
const rect = canvas.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
}
context.setTransform(dpr, 0, 0, dpr, 0, 0)
context.clearRect(0, 0, rect.width, rect.height)
context.fillStyle = '#061006'
context.fillRect(0, 0, rect.width, rect.height)
const cellWidth = rect.width / width
const cellHeight = rect.height / historyRows
let row = [...sourceRow]
for (let y = 0; y < historyRows; y += 1) {
const age = y / Math.max(1, historyRows - 1)
for (let x = 0; x < width; x += 1) {
if (row[x]) {
const green = Math.round(190 + age * 55)
const red = Math.round(112 + age * 80)
context.fillStyle = `rgb(${red} ${green} 92)`
context.fillRect(x * cellWidth, y * cellHeight, Math.ceil(cellWidth), Math.ceil(cellHeight))
}
}
row = nextElementaryRow(row, rule)
}
context.strokeStyle = 'rgba(240, 201, 74, 0.32)'
context.lineWidth = 1
context.strokeRect(0.5, 0.5, Math.max(0, rect.width - 1), Math.max(0, rect.height - 1))
}, [canvasRevision, historyRows, rule, sourceRow, width])
function toggleColumn(event: React.PointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current
if (!canvas || width === 0) return
const rect = canvas.getBoundingClientRect()
const x = Math.floor(((event.clientX - rect.left) / rect.width) * width)
if (x < 0 || x >= width) return
const current = cellsRef.current
const next = current.map((row, rowIndex) =>
rowIndex === sourceRowIndex ? row.map((alive, columnIndex) => (columnIndex === x ? !alive : alive)) : row
)
cellsRef.current = next
onCellsChange(next)
}
return (
<div className="board-wrap elementary-renderer-wrap">
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
<canvas
ref={canvasRef}
aria-label={`Elementary cellular automaton rule ${rule}`}
className="elementary-canvas"
role="img"
onPointerDown={toggleColumn}
/>
</div>
)
}
function Wildfire2DRenderer({ caption, cells, onCellsChange }: CaRendererProps) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
const cellsRef = React.useRef(cells)
const paintValueRef = React.useRef<boolean | null>(null)
const [canvasRevision, setCanvasRevision] = React.useState(0)
const width = cells[0]?.length ?? 0
const height = cells.length
React.useEffect(() => {
cellsRef.current = cells
}, [cells])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const observer = new ResizeObserver(() => setCanvasRevision((revision) => revision + 1))
observer.observe(canvas)
return () => observer.disconnect()
}, [])
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas || width === 0 || height === 0) return
const context = canvas.getContext('2d')
if (!context) return
const rect = canvas.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
}
context.setTransform(dpr, 0, 0, dpr, 0, 0)
context.clearRect(0, 0, rect.width, rect.height)
const cellSize = Math.min(rect.width / width, rect.height / height)
const boardWidth = cellSize * width
const boardHeight = cellSize * height
const offsetX = (rect.width - boardWidth) / 2
const offsetY = (rect.height - boardHeight) / 2
context.fillStyle = '#061006'
context.fillRect(0, 0, rect.width, rect.height)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const px = offsetX + x * cellSize
const py = offsetY + y * cellSize
if (cells[y][x]) {
const gradient = context.createRadialGradient(
px + cellSize * 0.5,
py + cellSize * 0.52,
cellSize * 0.08,
px + cellSize * 0.5,
py + cellSize * 0.52,
cellSize * 0.62
)
gradient.addColorStop(0, '#fff5b9')
gradient.addColorStop(0.45, '#f0c94a')
gradient.addColorStop(1, '#d9481e')
context.fillStyle = gradient
} else {
context.fillStyle = (x + y) % 2 === 0 ? '#143f1b' : '#0d2b14'
}
context.fillRect(px, py, Math.ceil(cellSize), Math.ceil(cellSize))
}
}
}, [canvasRevision, cells, height, width])
function cellFromPointer(event: React.PointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current
if (!canvas || width === 0 || height === 0) return null
const rect = canvas.getBoundingClientRect()
const cellSize = Math.min(rect.width / width, rect.height / height)
const boardWidth = cellSize * width
const boardHeight = cellSize * height
const offsetX = (rect.width - boardWidth) / 2
const offsetY = (rect.height - boardHeight) / 2
const x = Math.floor((event.clientX - rect.left - offsetX) / cellSize)
const y = Math.floor((event.clientY - rect.top - offsetY) / cellSize)
if (x < 0 || y < 0 || x >= width || y >= height) return null
return { x, y }
}
function paintCell(x: number, y: number, value: boolean) {
const current = cellsRef.current
if (current[y]?.[x] === value) return
const next = current.map((row, rowIndex) =>
rowIndex === y ? row.map((alive, columnIndex) => (columnIndex === x ? value : alive)) : row
)
cellsRef.current = next
onCellsChange(next)
}
function handlePointerDown(event: React.PointerEvent<HTMLCanvasElement>) {
const cell = cellFromPointer(event)
if (!cell) return
event.currentTarget.setPointerCapture(event.pointerId)
const nextValue = !cellsRef.current[cell.y][cell.x]
paintValueRef.current = nextValue
paintCell(cell.x, cell.y, nextValue)
}
function handlePointerMove(event: React.PointerEvent<HTMLCanvasElement>) {
if (paintValueRef.current === null) return
const cell = cellFromPointer(event)
if (!cell) return
paintCell(cell.x, cell.y, paintValueRef.current)
}
function stopPainting(event: React.PointerEvent<HTMLCanvasElement>) {
paintValueRef.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
return (
<div className="board-wrap wildfire-renderer-wrap">
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
<canvas
ref={canvasRef}
aria-label="Wildfire cellular automaton editor"
className="wildfire-canvas"
role="img"
onPointerCancel={stopPainting}
onPointerDown={handlePointerDown}
onPointerLeave={stopPainting}
onPointerMove={handlePointerMove}
onPointerUp={stopPainting}
/>
</div>
)
}
registerCaRendererRuntime(
{
id: '2d-canvas',
label: '2D Canvas',
supportedClasses: [{ dimensions: 2, states: 2 }],
Component: Canvas2DRenderer
},
{ replace: true }
)
registerCaRendererRuntime(
{
id: 'elementary-1d',
label: 'Elementary 1D',
aliases: ['elementary-ca', '1d-canvas'],
supportedClasses: [{ dimensions: 1, states: 2 }],
Component: Elementary1DRenderer
},
{ replace: true }
)
registerCaRendererRuntime(
{
id: 'wildfire-2d',
label: 'Wildfire 2D',
aliases: ['forest-fire-2d'],
supportedClasses: [{ dimensions: 2, states: 2 }],
Component: Wildfire2DRenderer
},
{ replace: true }
)
registerCaRendererRuntime(
{
id: 'voxel-3d',
label: 'Voxel 3D',
aliases: ['three-voxel'],
supportedClasses: [{ dimensions: 3, states: 2 }],
Component: Voxel3DRendererAdapter
},
{ replace: true }
)
+113
View File
@@ -0,0 +1,113 @@
import React from 'react'
import type { PresetNode } from './types.js'
interface PresetAssetTreeProps {
assignedNodeId?: string
nodes: PresetNode[]
selectedNodeId?: string
onSelect: (node: PresetNode) => void
}
interface TreeItem {
node: PresetNode
children: TreeItem[]
}
function compareNodes(left: PresetNode, right: PresetNode) {
return left.sort_order - right.sort_order || left.name.localeCompare(right.name)
}
function buildTree(nodes: PresetNode[]) {
const byParent = new Map<string | null, PresetNode[]>()
for (const node of nodes) {
const siblings = byParent.get(node.parent_id) ?? []
siblings.push(node)
byParent.set(node.parent_id, siblings)
}
function build(parentId: string | null): TreeItem[] {
return (byParent.get(parentId) ?? [])
.sort(compareNodes)
.map((node) => ({ node, children: build(node.id) }))
}
return build(null)
}
export function PresetAssetTree({
assignedNodeId,
nodes,
selectedNodeId,
onSelect
}: PresetAssetTreeProps) {
const tree = React.useMemo(() => buildTree(nodes), [nodes])
if (tree.length === 0) return <p className="empty">This CA library has no presets.</p>
return (
<div className="asset-tree">
{tree.map((item) => (
<AssetTreeItem
assignedNodeId={assignedNodeId}
item={item}
key={item.node.id}
selectedNodeId={selectedNodeId}
onSelect={onSelect}
/>
))}
</div>
)
}
function AssetTreeItem({
assignedNodeId,
item,
selectedNodeId,
onSelect
}: {
assignedNodeId?: string
item: TreeItem
selectedNodeId?: string
onSelect: (node: PresetNode) => void
}) {
const [open, setOpen] = React.useState(true)
const selected = item.node.id === selectedNodeId
const assigned = item.node.id === assignedNodeId
return (
<div className="asset-tree-item">
<div className={`asset-tree-row${selected ? ' selected' : ''}${assigned ? ' assigned' : ''}`}>
<button
aria-label={item.children.length ? `${open ? 'Collapse' : 'Expand'} ${item.node.name}` : `${item.node.name} has no children`}
className="asset-tree-arrow"
disabled={!item.children.length}
type="button"
onClick={() => setOpen((current) => !current)}
>
{item.children.length ? (open ? '▾' : '▸') : '·'}
</button>
<button className="asset-tree-select" type="button" onClick={() => onSelect(item.node)}>
<strong>{item.node.name}</strong>
<small>
{item.node.kind}
{assigned ? ' · current' : ''}
</small>
{item.node.description ? <span>{item.node.description}</span> : null}
</button>
</div>
{open && item.children.length ? (
<div className="asset-tree-children">
{item.children.map((child) => (
<AssetTreeItem
assignedNodeId={assignedNodeId}
item={child}
key={child.node.id}
selectedNodeId={selectedNodeId}
onSelect={onSelect}
/>
))}
</div>
) : null}
</div>
)
}
+679
View File
@@ -0,0 +1,679 @@
import React from 'react'
import { getCaClassFromParams, supportsCaClass } from './caRuntime.js'
import type { CaEngine, JsonObject, PresetNode } from './types.js'
interface PresetNodeTreeProps {
assignedNodeId?: string
engines?: CaEngine[]
nodes: PresetNode[]
selectedNodeId?: string
usedNodeIds: Set<string>
onCreateChild: (node: PresetNode) => void
onCreateGroup: (node: PresetNode) => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onMove: (node: PresetNode, direction: 1 | -1) => void
onRename: (node: PresetNode, name: string) => void | Promise<void>
onSelect: (node: PresetNode) => void
onSet: (node: PresetNode, path: string[], value: unknown) => void
onUnset: (node: PresetNode, path: string[]) => void
}
interface TreeItem {
node: PresetNode
children: TreeItem[]
siblingIndex: number
siblingCount: number
}
interface VisibleTreeItem {
item: TreeItem
inheritedParams: JsonObject
parentId: string | null
}
function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function valuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
function getParamPath(value: JsonObject, path: string[]) {
let current: unknown = value
for (const segment of path) {
if (!isPlainObject(current) || !(segment in current)) return undefined
current = current[segment]
}
return current
}
function formatParamValue(path: string[], value: unknown) {
if (path.join('.') === 'simulation.initialCondition' && isPlainObject(value)) {
const cells = Array.isArray(value.cells) ? value.cells.length : 0
return `${cells} live cells`
}
if (Array.isArray(value)) return `${value.length} items`
if (isPlainObject(value)) return 'object'
return String(value)
}
function mergeParamObjects(base: JsonObject, patch: JsonObject): JsonObject {
const merged: JsonObject = { ...base }
for (const [key, value] of Object.entries(patch)) {
const current = merged[key]
merged[key] = isPlainObject(current) && isPlainObject(value) ? mergeParamObjects(current, value) : value
}
return merged
}
function compareNodesByOrder(left: PresetNode, right: PresetNode) {
return left.sort_order - right.sort_order || left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug)
}
function buildTree(nodes: PresetNode[]): TreeItem[] {
const byParent = new Map<string | null, PresetNode[]>()
for (const node of nodes) {
const siblings = byParent.get(node.parent_id) ?? []
siblings.push(node)
byParent.set(node.parent_id, siblings)
}
function build(parentId: string | null): TreeItem[] {
const siblings = (byParent.get(parentId) ?? []).sort(compareNodesByOrder)
return siblings.map((node, index) => ({
node,
children: build(node.id),
siblingIndex: index,
siblingCount: siblings.length
}))
}
return build(null)
}
function flattenVisibleTree(
items: TreeItem[],
expandedNodeIds: Set<string>,
inheritedParams: JsonObject = {},
parentId: string | null = null
): VisibleTreeItem[] {
const visible: VisibleTreeItem[] = []
for (const item of items) {
visible.push({ item, inheritedParams, parentId })
if (expandedNodeIds.has(item.node.id)) {
const resolvedParams = mergeParamObjects(inheritedParams, item.node.params)
visible.push(...flattenVisibleTree(item.children, expandedNodeIds, resolvedParams, item.node.id))
}
}
return visible
}
export function PresetNodeTree({
assignedNodeId,
engines = [],
nodes,
selectedNodeId,
usedNodeIds,
onCreateChild,
onCreateGroup,
onDelete,
onMove,
onRename,
onSelect,
onSet,
onUnset
}: PresetNodeTreeProps) {
const tree = React.useMemo(() => buildTree(nodes), [nodes])
const [expandedNodeIds, setExpandedNodeIds] = React.useState<Set<string>>(
() => new Set(nodes.map((node) => node.id))
)
const [editingNodeId, setEditingNodeId] = React.useState('')
const [openMenuNodeId, setOpenMenuNodeId] = React.useState('')
const visibleItems = React.useMemo(
() => flattenVisibleTree(tree, expandedNodeIds),
[expandedNodeIds, tree]
)
React.useEffect(() => {
setExpandedNodeIds((current) => {
const next = new Set([...current].filter((id) => nodes.some((node) => node.id === id)))
for (const node of nodes) {
if (!current.has(node.id)) next.add(node.id)
}
return next
})
}, [nodes])
React.useEffect(() => {
if (!openMenuNodeId) return
function closeMenu(event: PointerEvent) {
const target = event.target
if (!(target instanceof Element)) return
if (target.closest('.tree-node-action-menu') || target.closest('.node-action-trigger')) return
setOpenMenuNodeId('')
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key === 'Escape') setOpenMenuNodeId('')
}
document.addEventListener('pointerdown', closeMenu)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('pointerdown', closeMenu)
document.removeEventListener('keydown', closeOnEscape)
}
}, [openMenuNodeId])
function toggleNode(nodeId: string, open?: boolean) {
setExpandedNodeIds((current) => {
const next = new Set(current)
const shouldOpen = open ?? !next.has(nodeId)
if (shouldOpen) next.add(nodeId)
else next.delete(nodeId)
return next
})
}
function handleTreeKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(event.key)) return
const selectedIndex = visibleItems.findIndex(({ item }) => item.node.id === selectedNodeId)
const currentIndex = selectedIndex === -1 ? 0 : selectedIndex
const current = visibleItems[currentIndex]
if (!current) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
const offset = event.key === 'ArrowDown' ? 1 : -1
const next = visibleItems[Math.min(Math.max(currentIndex + offset, 0), visibleItems.length - 1)]
if (next) onSelect(next.item.node)
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
if (current.item.children.length && !expandedNodeIds.has(current.item.node.id)) {
toggleNode(current.item.node.id, true)
} else if (current.item.children[0]) {
onSelect(current.item.children[0].node)
}
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
if (expandedNodeIds.has(current.item.node.id) && current.item.children.length) {
toggleNode(current.item.node.id, false)
} else if (current.parentId) {
const parent = nodes.find((node) => node.id === current.parentId)
if (parent) onSelect(parent)
}
return
}
event.preventDefault()
setEditingNodeId(current.item.node.id)
}
if (tree.length === 0) {
return <p className="empty">No nodes loaded.</p>
}
return (
<div className="preset-node-tree" role="tree" tabIndex={0} onKeyDown={handleTreeKeyDown}>
{tree.map((node) => (
<TreeNodeView
key={node.node.id}
item={node}
assignedNodeId={assignedNodeId}
engines={engines}
inheritedParams={{}}
selectedNodeId={selectedNodeId}
usedNodeIds={usedNodeIds}
expandedNodeIds={expandedNodeIds}
editingNodeId={editingNodeId}
openMenuNodeId={openMenuNodeId}
onCreateChild={onCreateChild}
onCreateGroup={onCreateGroup}
onDelete={onDelete}
onEdit={setEditingNodeId}
onMenuToggle={(nodeId) => setOpenMenuNodeId((current) => (current === nodeId ? '' : nodeId))}
onMove={onMove}
onRename={onRename}
onSelect={onSelect}
onSet={onSet}
onToggle={toggleNode}
onUnset={onUnset}
/>
))}
</div>
)
}
function TreeNodeView({
item,
assignedNodeId,
engines,
inheritedParams,
selectedNodeId,
usedNodeIds,
expandedNodeIds,
editingNodeId,
openMenuNodeId,
onCreateChild,
onCreateGroup,
onDelete,
onEdit,
onMenuToggle,
onMove,
onRename,
onSelect,
onSet,
onToggle,
onUnset
}: {
item: TreeItem
assignedNodeId?: string
engines: CaEngine[]
inheritedParams: JsonObject
selectedNodeId?: string
usedNodeIds: Set<string>
expandedNodeIds: Set<string>
editingNodeId: string
openMenuNodeId: string
onCreateChild: (node: PresetNode) => void
onCreateGroup: (node: PresetNode) => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onEdit: (nodeId: string) => void
onMenuToggle: (nodeId: string) => void
onMove: (node: PresetNode, direction: 1 | -1) => void
onRename: (node: PresetNode, name: string) => void | Promise<void>
onSelect: (node: PresetNode) => void
onSet: (node: PresetNode, path: string[], value: unknown) => void
onToggle: (nodeId: string, open?: boolean) => void
onUnset: (node: PresetNode, path: string[]) => void
}) {
const open = expandedNodeIds.has(item.node.id)
const menuOpen = openMenuNodeId === item.node.id
const selected = item.node.id === selectedNodeId
const assigned = item.node.id === assignedNodeId
const used = usedNodeIds.has(item.node.id)
const usedDescendantCount = React.useMemo(() => {
function countUsedDescendants(children: TreeItem[]): number {
return children.reduce(
(total, child) =>
total + (usedNodeIds.has(child.node.id) ? 1 : 0) + countUsedDescendants(child.children),
0
)
}
return countUsedDescendants(item.children)
}, [item.children, usedNodeIds])
const resolvedParams = mergeParamObjects(inheritedParams, item.node.params)
const kindLabel = item.node.kind === 'preset_root' ? 'root' : item.node.kind === 'chapter' ? 'group' : item.node.kind
const usageLabel = assigned
? `${kindLabel} · assigned`
: used
? `${kindLabel} · used by slides`
: usedDescendantCount > 0
? `${kindLabel} · contains ${usedDescendantCount} used preset${usedDescendantCount === 1 ? '' : 's'}`
: `${kindLabel} · unused`
async function deleteFromMenu() {
onMenuToggle(item.node.id)
if (!window.confirm(`Delete preset "${item.node.name}"? This cannot be undone.`)) return
await onDelete(item.node)
}
return (
<div className="tree-item">
<div className={`tree-node${selected ? ' active' : ''}${assigned ? ' assigned' : ''}${used || usedDescendantCount > 0 ? ' used' : ' unused'}`}>
<button
aria-label={item.children.length ? `${open ? 'Collapse' : 'Expand'} ${item.node.name}` : `${item.node.name} has no children`}
className="tree-node-arrow"
disabled={!item.children.length}
type="button"
onClick={() => onToggle(item.node.id)}
>
{item.children.length ? (open ? '▾' : '▸') : '•'}
</button>
<button
aria-current={selected ? 'true' : undefined}
className="tree-node-main"
role="treeitem"
type="button"
onClick={() => onSelect(item.node)}
>
<strong>{item.node.name}</strong>
<small>{usageLabel}</small>
</button>
<div className="tree-node-action-shell">
<button
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label={`Actions for ${item.node.name}`}
className="icon-button tree-node-edit node-action-trigger"
title="Preset actions"
type="button"
onClick={(event) => {
event.stopPropagation()
onMenuToggle(item.node.id)
}}
>
</button>
{menuOpen ? (
<div className="tree-node-action-menu" role="menu">
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onEdit(item.node.id)
}}>
Edit
</button>
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onCreateChild(item.node)
}}>
Create child preset
</button>
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onCreateGroup(item.node)
}}>
Create child group
</button>
<button disabled={item.siblingIndex === 0} role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onMove(item.node, -1)
}}>
Move up
</button>
<button disabled={item.siblingIndex >= item.siblingCount - 1} role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onMove(item.node, 1)
}}>
Move down
</button>
<button className="danger" role="menuitem" type="button" onClick={() => void deleteFromMenu()}>
Delete
</button>
</div>
) : null}
</div>
</div>
{editingNodeId === item.node.id ? (
<NodePropertiesModal
engines={engines}
inheritedParams={inheritedParams}
node={item.node}
resolvedParams={resolvedParams}
onClose={() => onEdit('')}
onDelete={onDelete}
onRename={onRename}
onSet={onSet}
onUnset={onUnset}
/>
) : null}
{open && item.children.length ? (
<div className="tree-children">
{item.children.map((child) => (
<TreeNodeView
assignedNodeId={assignedNodeId}
engines={engines}
inheritedParams={resolvedParams}
item={child}
key={child.node.id}
usedNodeIds={usedNodeIds}
expandedNodeIds={expandedNodeIds}
editingNodeId={editingNodeId}
openMenuNodeId={openMenuNodeId}
onCreateChild={onCreateChild}
onCreateGroup={onCreateGroup}
onDelete={onDelete}
onEdit={onEdit}
onMenuToggle={onMenuToggle}
onMove={onMove}
onRename={onRename}
onSelect={onSelect}
onSet={onSet}
onToggle={onToggle}
onUnset={onUnset}
selectedNodeId={selectedNodeId}
/>
))}
</div>
) : null}
</div>
)
}
function collectResolvedParams(value: unknown, prefix: string[] = []): Array<{ path: string[]; value: unknown }> {
if (!isPlainObject(value)) return []
const entries: Array<{ path: string[]; value: unknown }> = []
for (const [key, child] of Object.entries(value)) {
const path = [...prefix, key]
if (path.join('.') === 'simulation.initialCondition' || !isPlainObject(child)) {
entries.push({ path, value: child })
} else {
entries.push(...collectResolvedParams(child, path))
}
}
return entries
}
function isGenericEditableProperty(path: string[]) {
const joinedPath = path.join('.')
return (
joinedPath !== 'simulation.initialCondition' &&
joinedPath !== 'simulation.engineId' &&
joinedPath !== 'simulation.neighborhoodId' &&
!joinedPath.startsWith('caClass.')
)
}
function engineParamPath(key: string) {
if (key === 'rendererId') return ['renderer', 'id']
if (key === 'ruleId') return ['simulation', 'ruleId']
if (key.startsWith('grid.')) return ['simulation', ...key.split('.')]
if (key.includes('.')) return key.split('.')
return ['simulation', key]
}
function engineSettingKeys(engine: CaEngine | undefined) {
return engine ? Object.keys(engine.params_schema) : []
}
function isEngineSettingPath(path: string[], engine: CaEngine | undefined) {
const joinedPath = path.join('.')
return engineSettingKeys(engine).some((key) => engineParamPath(key).join('.') === joinedPath)
}
function parsePropertyDraft(value: string) {
const trimmed = value.trim()
if (trimmed === '') return ''
try {
return JSON.parse(trimmed) as unknown
} catch {
return value
}
}
function formatPropertyDraft(value: unknown) {
if (typeof value === 'string') return value
return JSON.stringify(value, null, 2)
}
function NodePropertiesModal({
engines,
inheritedParams,
node,
resolvedParams,
onClose,
onDelete,
onRename,
onSet,
onUnset
}: {
engines: CaEngine[]
inheritedParams: JsonObject
node: PresetNode
resolvedParams: JsonObject
onClose: () => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onRename: (node: PresetNode, name: string) => void | Promise<void>
onSet: (node: PresetNode, path: string[], value: unknown) => void
onUnset: (node: PresetNode, path: string[]) => void
}) {
const [nameDraft, setNameDraft] = React.useState(node.name)
const resolvedEngineId = getParamPath(resolvedParams, ['simulation', 'engineId'])
const caClass = getCaClassFromParams(resolvedParams)
const resolvedEngine =
typeof resolvedEngineId === 'string'
? engines.find((engine) => engine.engine_kind === resolvedEngineId)
: undefined
const resolvedEngineCompatible = resolvedEngine ? supportsCaClass(resolvedEngine, caClass) : false
const activeEngine = resolvedEngineCompatible ? resolvedEngine : undefined
const properties = collectResolvedParams(resolvedParams).filter(
(property) => isGenericEditableProperty(property.path) && !isEngineSettingPath(property.path, activeEngine)
)
React.useEffect(() => {
setNameDraft(node.name)
}, [node.name])
async function saveName() {
const name = nameDraft.trim()
if (!name) {
setNameDraft(node.name)
return
}
if (name !== node.name) await onRename(node, name)
}
async function deleteNode() {
if (!window.confirm(`Delete preset "${node.name}"? This cannot be undone.`)) return
const deleted = await onDelete(node)
if (deleted === false) return
onClose()
}
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<section aria-modal="true" className="property-modal" role="dialog" onMouseDown={(event) => event.stopPropagation()}>
<header className="property-modal-header">
<div>
<p className="eyebrow">Preset Editor</p>
<h2>Edit preset</h2>
</div>
<button type="button" onClick={onClose}>
Close
</button>
</header>
<div className="property-modal-list">
<section className="preset-modal-stage preset-modal-identity">
<div className="preset-modal-stage-heading">
<span>i</span>
<div>
<h3>Preset identity</h3>
<p>Edit CA space, engine, and engine settings in the inspector.</p>
</div>
</div>
<label>
Preset name
<input
autoFocus
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void saveName()
}
}}
/>
</label>
<button disabled={!nameDraft.trim() || nameDraft.trim() === node.name} type="button" onClick={() => void saveName()}>
Save name
</button>
</section>
{properties.map((property) => {
const localValue = getParamPath(node.params, property.path)
const inheritedValue = getParamPath(inheritedParams, property.path)
const isLocal = localValue !== undefined && !valuesEqual(localValue, inheritedValue)
return (
<PropertyEditorRow
inheritedValue={inheritedValue}
isLocal={isLocal}
key={property.path.join('.')}
node={node}
path={property.path}
value={property.value}
onSet={onSet}
onUnset={onUnset}
/>
)
})}
{properties.length === 0 ? <p className="empty">No properties are resolved for this node.</p> : null}
<footer className="preset-modal-danger">
<div>
<strong>Delete preset</strong>
<small>Slides referencing this preset must be reassigned first.</small>
</div>
<button className="danger" type="button" onClick={() => void deleteNode()}>
Delete
</button>
</footer>
</div>
</section>
</div>
)
}
function PropertyEditorRow({
inheritedValue,
isLocal,
node,
path,
value,
onSet,
onUnset
}: {
inheritedValue: unknown
isLocal: boolean
node: PresetNode
path: string[]
value: unknown
onSet: (node: PresetNode, path: string[], value: unknown) => void
onUnset: (node: PresetNode, path: string[]) => void
}) {
const [draft, setDraft] = React.useState(formatPropertyDraft(value))
React.useEffect(() => {
setDraft(formatPropertyDraft(value))
}, [value])
return (
<div className="property-row">
<div className="property-row-meta">
<strong>{path.join('.')}</strong>
<span className={isLocal ? 'local' : 'inherited'}>{isLocal ? 'local override' : 'inherited'}</span>
{!isLocal && inheritedValue !== undefined ? <small>from parent: {formatParamValue(path, inheritedValue)}</small> : null}
</div>
<textarea value={draft} onChange={(event) => setDraft(event.target.value)} />
<div className="property-row-actions">
<button type="button" onClick={() => onSet(node, path, parsePropertyDraft(draft))}>
Save
</button>
<button className="danger" disabled={!isLocal} type="button" onClick={() => onUnset(node, path)}>
🗑
</button>
</div>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import React from 'react'
import type { ResolvedScene } from './types.js'
interface SlideListProps {
activeSceneId: string
canDeleteSlide: boolean
sceneCount: number
scenes: ResolvedScene[]
onBack: () => void
onDeleteSlide: () => void
onEditScene: (scene: ResolvedScene) => void
onReorderSlides: (scenes: ResolvedScene[]) => void
onSaveNewSlide: () => void
onSelectScene: (scene: ResolvedScene) => void
}
export function SlideList({
activeSceneId,
canDeleteSlide,
sceneCount,
scenes,
onBack,
onDeleteSlide,
onEditScene,
onReorderSlides,
onSaveNewSlide,
onSelectScene
}: SlideListProps) {
const [draggedSceneId, setDraggedSceneId] = React.useState('')
const [dropTarget, setDropTarget] = React.useState<{ sceneId: string; position: 'before' | 'after' } | null>(null)
function captionPreview(scene: ResolvedScene) {
const caption = typeof scene.scene.params.caption === 'string' ? scene.scene.params.caption : ''
return caption.split(/\r?\n/, 1)[0].trim() || 'No caption'
}
function reorderScenes(targetSceneId: string, position: 'before' | 'after') {
if (!draggedSceneId || draggedSceneId === targetSceneId) return
const draggedScene = scenes.find((scene) => scene.scene.id === draggedSceneId)
if (!draggedScene) return
const withoutDragged = scenes.filter((scene) => scene.scene.id !== draggedSceneId)
const targetIndex = withoutDragged.findIndex((scene) => scene.scene.id === targetSceneId)
if (targetIndex === -1) return
const insertIndex = position === 'after' ? targetIndex + 1 : targetIndex
const reordered = [...withoutDragged]
reordered.splice(insertIndex, 0, draggedScene)
onReorderSlides(reordered)
}
return (
<aside className="panel slide-panel">
<div className="drawer-navigation">
<button type="button" onClick={onBack}>
Back
</button>
</div>
<div className="scene-list-header">
<p className="eyebrow">Slides</p>
<span>{sceneCount}</span>
</div>
{scenes.length ? (
scenes.map((scene) => (
<div
key={scene.scene.id}
className={`scene-item${scene.scene.id === activeSceneId ? ' active' : ''}${scene.scene.id === draggedSceneId ? ' dragging' : ''}${dropTarget?.sceneId === scene.scene.id ? ` drop-${dropTarget.position}` : ''}`}
draggable
onDragEnd={() => {
setDraggedSceneId('')
setDropTarget(null)
}}
onDragOver={(event) => {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
const position = event.clientY > rect.top + rect.height / 2 ? 'after' : 'before'
setDropTarget({ sceneId: scene.scene.id, position })
}}
onDragStart={(event) => {
setDraggedSceneId(scene.scene.id)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', scene.scene.id)
}}
onDrop={(event) => {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
const position = event.clientY > rect.top + rect.height / 2 ? 'after' : 'before'
reorderScenes(scene.scene.id, position)
setDraggedSceneId('')
setDropTarget(null)
}}
>
<button className="scene-item-main" type="button" onClick={() => onSelectScene(scene)}>
<span>{scene.scene.order_index}</span>
<strong>{scene.scene.title}</strong>
<small className="scene-caption-preview">{captionPreview(scene)}</small>
</button>
<button
aria-label={`Edit ${scene.scene.title} metadata`}
className="scene-edit-button icon-button"
type="button"
onClick={() => onEditScene(scene)}
>
</button>
</div>
))
) : (
<p className="empty">No slides yet. Choose a CA asset to create the first one.</p>
)}
<button className="primary full-width" type="button" onClick={onSaveNewSlide}>
New slide
</button>
<button className="danger full-width" disabled={!canDeleteSlide} type="button" onClick={onDeleteSlide}>
Delete selected slide
</button>
</aside>
)
}
+224
View File
@@ -0,0 +1,224 @@
import {
assertSameCellsShape,
cloneCells,
resolveBoundaryCondition,
resolveBoundaryIndex,
seededRandom
} from './caRuntime.js'
import type { CaClass, Cells, JsonObject, SceneParams } from './types.js'
export interface CaEngineRuntime {
id: string
label: string
supportedClasses?: CaClass[]
defaultParams?: JsonObject
step: (cells: Cells, settings: SceneParams) => Cells
}
const engineRuntimes = new Map<string, CaEngineRuntime>()
function emptyCellsLike(cells: Cells): Cells {
return cells.map((row) => row.map(() => false))
}
function parseOuterTotalisticRule(ruleId: string) {
const match = /^B([0-8]*)\/S([0-8]*)$/i.exec(ruleId)
if (!match) return { birth: new Set([3]), survival: new Set([2, 3]) }
return {
birth: new Set(match[1].split('').map(Number)),
survival: new Set(match[2].split('').map(Number))
}
}
function parseElementaryRule(ruleId: string | undefined) {
const match = /(?:rule[-_\s]*)?(\d{1,3})/i.exec(ruleId ?? '')
if (!match) return 110
return Math.max(0, Math.min(255, Number(match[1])))
}
function parseProbability(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback
}
function stepOuterTotalistic2d(cells: Cells, settings: SceneParams) {
const rule = parseOuterTotalisticRule(settings.simulation?.ruleId ?? 'B3/S23')
const next = emptyCellsLike(cells)
const height = cells.length
const width = cells[0]?.length ?? 0
const boundary = resolveBoundaryCondition(settings.simulation?.grid)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let neighbours = 0
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx === 0 && dy === 0) continue
const rawX = x + dx
const rawY = y + dy
const nx = resolveBoundaryIndex(rawX, width, boundary)
const ny = resolveBoundaryIndex(rawY, height, boundary)
if (nx === null || ny === null) continue
if (cells[ny][nx]) neighbours += 1
}
}
next[y][x] = cells[y][x] ? rule.survival.has(neighbours) : rule.birth.has(neighbours)
}
}
return next
}
function stepElementary1d(cells: Cells, settings: SceneParams) {
const rule = parseElementaryRule(settings.simulation?.ruleId)
const next = cloneCells(cells)
const width = cells[0]?.length ?? 0
if (cells.length === 0 || width === 0) return next
const boundary = resolveBoundaryCondition(settings.simulation?.grid)
const sourceRowIndex = Math.max(0, cells.findIndex((row) => row.some(Boolean)))
const sourceRow = cells[sourceRowIndex] ?? []
for (let x = 0; x < width; x += 1) {
const leftIndex = resolveBoundaryIndex(x - 1, width, boundary)
const rightIndex = resolveBoundaryIndex(x + 1, width, boundary)
const left = leftIndex === null ? 0 : sourceRow[leftIndex] ? 1 : 0
const center = sourceRow[x] ? 1 : 0
const right = rightIndex === null ? 0 : sourceRow[rightIndex] ? 1 : 0
const pattern = (left << 2) | (center << 1) | right
next[sourceRowIndex][x] = ((rule >> pattern) & 1) === 1
}
return next
}
function deterministicCellNoise(seed: string, x: number, y: number) {
const random = seededRandom(`${seed}:${x}:${y}`)
return random()
}
function stepWildfire2d(cells: Cells, settings: SceneParams) {
const next = emptyCellsLike(cells)
const height = cells.length
const width = cells[0]?.length ?? 0
const boundary = resolveBoundaryCondition(settings.simulation?.grid)
const spreadProbability = parseProbability(settings.simulation?.spreadProbability, 1)
const seed = typeof settings.simulation?.seed === 'string' ? settings.simulation.seed : 'wildfire'
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (cells[y][x]) {
next[y][x] = false
continue
}
let burningNeighbours = 0
const offsets = [
[0, -1],
[1, 0],
[0, 1],
[-1, 0]
] as const
for (const [dx, dy] of offsets) {
const nx = resolveBoundaryIndex(x + dx, width, boundary)
const ny = resolveBoundaryIndex(y + dy, height, boundary)
if (nx === null || ny === null) continue
if (cells[ny][nx]) burningNeighbours += 1
}
next[y][x] = burningNeighbours > 0 && deterministicCellNoise(seed, x, y) < spreadProbability
}
}
return next
}
function stepNoop(cells: Cells) {
return cloneCells(cells)
}
export function registerCaEngineRuntime(engine: CaEngineRuntime, options: { replace?: boolean } = {}) {
if (!engine.id.trim()) throw new Error('CA engine runtime id is required')
if (engineRuntimes.has(engine.id) && !options.replace) {
throw new Error(`CA engine runtime already registered: ${engine.id}`)
}
engineRuntimes.set(engine.id, engine)
}
export function listCaEngineRuntimes() {
return [...engineRuntimes.values()]
}
export function stepCaCells(cells: Cells, settings: SceneParams) {
const engineId = settings.simulation?.engineId ?? 'game-of-life-2d'
const engine = engineRuntimes.get(engineId) ?? engineRuntimes.get('outer-totalistic-2d') ?? engineRuntimes.get('noop')
const next = engine ? engine.step(cells, settings) : stepNoop(cells)
assertSameCellsShape(cells, next, `CA engine output (${engine?.id ?? 'fallback'})`)
return next
}
registerCaEngineRuntime(
{
id: 'game-of-life-2d',
label: 'Game of Life 2D',
supportedClasses: [{ dimensions: 2, states: 2 }],
defaultParams: { ruleId: 'B3/S23', rendererId: '2d-canvas' },
step: stepOuterTotalistic2d
},
{ replace: true }
)
registerCaEngineRuntime(
{
id: 'outer-totalistic-2d',
label: 'Outer-totalistic 2D',
supportedClasses: [{ dimensions: 2, states: 2 }],
defaultParams: { ruleId: 'B3/S23', rendererId: '2d-canvas' },
step: stepOuterTotalistic2d
},
{ replace: true }
)
registerCaEngineRuntime(
{
id: 'elementary-1d',
label: 'Elementary 1D',
supportedClasses: [{ dimensions: 1, states: 2 }],
defaultParams: { ruleId: 'Rule 110', rendererId: 'elementary-1d' },
step: stepElementary1d
},
{ replace: true }
)
registerCaEngineRuntime(
{
id: 'wildfire-2d',
label: 'Wildfire 2D',
supportedClasses: [{ dimensions: 2, states: 2 }],
defaultParams: { ruleId: 'wildfire-binary', rendererId: 'wildfire-2d', spreadProbability: 1 },
step: stepWildfire2d
},
{ replace: true }
)
registerCaEngineRuntime(
{
id: 'generic-voxel-ca',
label: 'Generic voxel CA',
step: stepNoop
},
{ replace: true }
)
registerCaEngineRuntime(
{
id: 'noop',
label: 'No evolution',
step: stepNoop
},
{ replace: true }
)
@@ -0,0 +1,93 @@
import { numericGridSize, seededRandom } from './caRuntime.js'
import type { CaClass, JsonObject, SceneParams } from './types.js'
export interface InitialConditionResult {
type: 'cells'
cells: number[][]
generator: {
kind: string
} & JsonObject
}
export interface InitialConditionRuntimeInput {
settings: SceneParams
caClass: CaClass
params: JsonObject
}
export interface InitialConditionRuntime {
id: string
label: string
generate: (input: InitialConditionRuntimeInput) => InitialConditionResult
}
const initialConditionRuntimes = new Map<string, InitialConditionRuntime>()
function clampDensity(value: unknown) {
return Math.max(0, Math.min(1, typeof value === 'number' ? value : 0.28))
}
function randomSoup({ settings, caClass, params }: InitialConditionRuntimeInput): InitialConditionResult {
const density = clampDensity(params.density)
const seed = typeof params.seed === 'string' ? params.seed : 'studio-seed'
const random = seededRandom(seed)
const [width, height, depth] = numericGridSize(settings.simulation?.grid, caClass.dimensions)
const cells: number[][] = []
for (let z = 0; z < depth; z += 1) {
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (random() < density) cells.push([x, y, z])
}
}
}
return {
type: 'cells',
cells,
generator: {
kind: 'random-soup',
density,
seed
}
}
}
export function registerInitialConditionRuntime(
runtime: InitialConditionRuntime,
options: { replace?: boolean } = {}
) {
if (!runtime.id.trim()) throw new Error('Initial condition runtime id is required')
if (initialConditionRuntimes.has(runtime.id) && !options.replace) {
throw new Error(`Initial condition runtime already registered: ${runtime.id}`)
}
initialConditionRuntimes.set(runtime.id, runtime)
}
export function listInitialConditionRuntimes() {
return [...initialConditionRuntimes.values()]
}
export function getInitialConditionRuntime(runtimeId: string) {
return initialConditionRuntimes.get(runtimeId) ?? null
}
export function generateInitialCondition(runtimeId: string, input: InitialConditionRuntimeInput) {
const runtime = getInitialConditionRuntime(runtimeId)
if (!runtime) throw new Error(`Initial condition runtime unavailable: ${runtimeId}`)
const result = runtime.generate(input)
if (result.type !== 'cells' || !Array.isArray(result.cells)) {
throw new Error(`Initial condition runtime returned an invalid result: ${runtimeId}`)
}
return result
}
registerInitialConditionRuntime(
{
id: 'random-soup',
label: 'Random soup',
generate: randomSoup
},
{ replace: true }
)
+128
View File
@@ -0,0 +1,128 @@
import type { BoundaryCondition, CaClass, Cells, JsonObject, SceneParams } from './types.js'
export const DEFAULT_BOUNDARY_CONDITION: BoundaryCondition = 'wrap'
export const DEFAULT_GRID_SIZE = 24
export function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export function statesEqual(left: CaClass['states'] | undefined, right: CaClass['states'] | undefined) {
if (left === undefined || right === undefined) return false
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right)) return false
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort())
}
return left === right
}
export function supportsCaClass(runtime: { supported_classes?: CaClass[]; supportedClasses?: CaClass[] }, caClass: CaClass | undefined) {
if (!caClass) return false
const supportedClasses = runtime.supported_classes ?? runtime.supportedClasses ?? []
return supportedClasses.some(
(supportedClass) =>
supportedClass.dimensions === caClass.dimensions &&
statesEqual(supportedClass.states, caClass.states) &&
neighborhoodsCompatible(supportedClass, caClass)
)
}
function neighborhoodsCompatible(supportedClass: CaClass, caClass: CaClass) {
if (!supportedClass.neighborhoodId || !caClass.neighborhoodId) return true
return supportedClass.neighborhoodId === caClass.neighborhoodId
}
export function getCaClassFromParams(params: JsonObject | undefined): CaClass | undefined {
const caClass = params?.caClass
if (!isPlainObject(caClass)) return undefined
const dimensions = typeof caClass.dimensions === 'number' ? caClass.dimensions : undefined
const states = caClass.states
if (!dimensions || (typeof states !== 'number' && !Array.isArray(states))) return undefined
return {
neighborhoodId: typeof caClass.neighborhoodId === 'string' ? caClass.neighborhoodId : undefined,
dimensions,
states
}
}
export function numericGridSize(value: unknown, dimensions: number, fallbackSize = DEFAULT_GRID_SIZE) {
const grid = isPlainObject(value) ? value : {}
const size = Array.isArray(grid.size) ? grid.size : []
const x = typeof size[0] === 'number' ? size[0] : fallbackSize
const y = dimensions >= 2 && typeof size[1] === 'number' ? size[1] : 1
const z = dimensions >= 3 && typeof size[2] === 'number' ? size[2] : 1
return [x, y, z] as const
}
export function seededRandom(seed: string) {
let state = 2166136261
for (let index = 0; index < seed.length; index += 1) {
state ^= seed.charCodeAt(index)
state = Math.imul(state, 16777619)
}
return () => {
state += 0x6d2b79f5
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export function isBoundaryCondition(value: unknown): value is BoundaryCondition {
return value === 'wrap' || value === 'mirror' || value === 'fixed'
}
export function getBoundaryCondition(params: SceneParams | undefined): BoundaryCondition {
return resolveBoundaryCondition(params?.simulation?.grid)
}
export function resolveBoundaryCondition(grid: JsonObject | undefined): BoundaryCondition {
if (isBoundaryCondition(grid?.boundary)) return grid.boundary
if (grid?.wrap === false) return 'fixed'
return DEFAULT_BOUNDARY_CONDITION
}
export function wrapIndex(index: number, size: number) {
return ((index % size) + size) % size
}
export function mirrorIndex(index: number, size: number) {
if (size <= 1) return 0
const period = (size - 1) * 2
const wrapped = wrapIndex(index, period)
return wrapped <= size - 1 ? wrapped : period - wrapped
}
export function resolveBoundaryIndex(index: number, size: number, boundary: BoundaryCondition) {
if (size <= 0) return null
if (boundary === 'wrap') return wrapIndex(index, size)
if (boundary === 'mirror') return mirrorIndex(index, size)
return index >= 0 && index < size ? index : null
}
export function cloneCells(cells: Cells): Cells {
return cells.map((row) => [...row])
}
export function assertCellsShape(cells: Cells, label = 'cells') {
const width = cells[0]?.length ?? 0
for (const row of cells) {
if (row.length !== width) throw new Error(`${label} must be rectangular`)
for (const value of row) {
if (typeof value !== 'boolean') throw new Error(`${label} must contain boolean cell states`)
}
}
}
export function assertSameCellsShape(reference: Cells, next: Cells, label = 'engine output') {
assertCellsShape(reference, 'input cells')
assertCellsShape(next, label)
if (next.length !== reference.length || (next[0]?.length ?? 0) !== (reference[0]?.length ?? 0)) {
throw new Error(`${label} must preserve the input grid dimensions`)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
export type JsonObject = Record<string, unknown>
export type Cells = boolean[][]
export type BoundaryCondition = 'wrap' | 'mirror' | 'fixed'
export interface Deck {
id: string
slug: string
title: string
description: string | null
params: JsonObject
}
export interface PresetTree {
id: string
slug: string
name: string
node_count: number
}
export interface PresetNode {
id: string
tree_id: string
parent_id: string | null
slug: string
name: string
kind: string
description: string | null
sort_order: number
params: JsonObject
}
export interface PresetNodeUsage {
node_id: string
scene_count: number
}
export interface Scene {
id: string
deck_id: string
order_index: number
title: string
preset_tree_id: string | null
preset_node_id: string | null
apply_mode: 'reinitialize' | 'patch_existing'
requires_previous_scene: boolean
params: JsonObject
}
export interface SceneParams extends JsonObject {
caClass?: {
neighborhoodId?: string
dimensions?: number
states?: number | string[]
}
simulation?: {
initialCondition?: {
type?: string
cells?: number[][]
}
ruleId?: string
neighborhoodId?: string
engineId?: string
grid?: JsonObject
} & JsonObject
renderer?: {
id?: string
} & JsonObject
}
export interface CaClass {
neighborhoodId?: string
dimensions: number
states: number | string[]
}
export interface InitialConditionGenerator {
id: string
slug: string
name: string
description: string | null
generator_kind: string
supported_classes: CaClass[]
params_schema: JsonObject
default_params: JsonObject
}
export interface CaEngine {
id: string
slug: string
name: string
description: string | null
engine_kind: string
license: string | null
owner: string | null
ip_notice: string | null
supported_classes: CaClass[]
params_schema: JsonObject
default_params: JsonObject
}
export interface CaEnginePresetUsage {
tree_id: string
tree_name: string
node_id: string
node_name: string
node_kind: string
}
export interface ResolvedScene {
scene: Scene
ancestry: PresetNode[]
params: SceneParams
}
export interface ResolvedDeck {
deck: Deck
scenes: ResolvedScene[]
}
export interface ResolvedNode {
treeId: string
nodeId: string
ancestry: PresetNode[]
params: SceneParams
}
export interface DeckStudioParams {
presetTreeId?: string
rootNodeId?: string
rendererId?: string
neighborhoodId?: string
ruleId?: string
}
@@ -0,0 +1,74 @@
import React from 'react'
import type { SceneParams } from '../types.js'
import { createVoxelRendererCore } from './voxelRendererCore.js'
import type { VoxelCell, VoxelRendererOptions } from './voxelRendererCore.js'
function numberFromGrid(settings: SceneParams) {
const size = settings.simulation?.grid?.size
if (!Array.isArray(size)) return 16
const first = size[0]
return typeof first === 'number' ? Math.min(30, Math.max(8, Math.round(first))) : 16
}
function voxelRuleId(settings: SceneParams) {
const ruleId = settings.simulation?.ruleId
return typeof ruleId === 'string' && ruleId.endsWith('-3d') ? ruleId : 'life-3d'
}
function rendererOptions(settings: SceneParams): VoxelRendererOptions {
const renderer = settings.renderer ?? {}
return {
accentColor: typeof renderer.accentColor === 'string' ? renderer.accentColor : '#f0c94a',
cellGap: typeof renderer.cellGap === 'number' ? renderer.cellGap : 0.14,
primaryColor: typeof renderer.primaryColor === 'string' ? renderer.primaryColor : '#70f45f',
showBounds: typeof renderer.showBounds === 'boolean' ? renderer.showBounds : true
}
}
function voxelCells(settings: SceneParams): VoxelCell[] {
const cells = settings.simulation?.initialCondition?.cells
if (!Array.isArray(cells)) return []
return cells.flatMap((cell) => {
const [x, y, z = 0, state = 1] = cell
if (!Number.isInteger(x) || !Number.isInteger(y) || !Number.isInteger(z)) return []
return [{ x, y, z, state: typeof state === 'number' ? state : 1 }]
})
}
export function Voxel3DRenderer({ caption, settings }: { caption: string; settings: SceneParams }) {
const mountRef = React.useRef<HTMLDivElement | null>(null)
const rendererRef = React.useRef<ReturnType<typeof createVoxelRendererCore> | null>(null)
const cells = React.useMemo(() => voxelCells(settings), [settings])
const size = numberFromGrid(settings)
const ruleId = voxelRuleId(settings)
const options = React.useMemo(() => rendererOptions(settings), [settings])
React.useEffect(() => {
const mount = mountRef.current
if (!mount) return undefined
const renderer = createVoxelRendererCore(mount, options)
rendererRef.current = renderer
renderer.update({ cells, options, ruleId, size })
return () => {
renderer.dispose()
rendererRef.current = null
}
// The core owns the WebGL lifecycle; param changes update through the effect below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
React.useEffect(() => {
rendererRef.current?.update({ cells, options, ruleId, size })
}, [cells, options, ruleId, size])
return (
<div className="board-wrap voxel-renderer-wrap">
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
<div className="studio-voxel-viewport" ref={mountRef} />
{cells.length === 0 ? <div className="voxel-empty-hint">No voxel initial condition on this node</div> : null}
</div>
)
}
@@ -0,0 +1,268 @@
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
const WORLD_SIZE = 16
const CAMERA_DISTANCE = 28
const CAMERA_HEIGHT = 20
export interface VoxelCell {
x: number
y: number
z: number
state?: number
}
export interface VoxelRendererOptions {
accentColor: string
cellGap: number
primaryColor: string
showBounds: boolean
}
export interface VoxelRendererCore {
dispose: () => void
render: () => void
update: (input: { cells: VoxelCell[]; options: VoxelRendererOptions; ruleId: string; size: number }) => void
}
interface SceneBundle {
animationFrame: number
bounds: THREE.LineSegments
camera: THREE.PerspectiveCamera
controls: OrbitControls
grid: THREE.GridHelper
mesh: THREE.InstancedMesh | null
mountNode: HTMLDivElement
primaryColor: THREE.Color
accentColor: THREE.Color
renderer: THREE.WebGLRenderer
resizeObserver: ResizeObserver
scene: THREE.Scene
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function disposeMaterial(material: THREE.Material | THREE.Material[]) {
const materials = Array.isArray(material) ? material : [material]
for (const item of materials) {
item.dispose()
}
}
function disposeMesh(mesh: THREE.InstancedMesh | null) {
if (!mesh) return
mesh.geometry.dispose()
disposeMaterial(mesh.material)
}
function createVoxelMaterial(primaryColor: THREE.Color) {
return new THREE.MeshStandardMaterial({
color: primaryColor,
emissive: primaryColor.clone().multiplyScalar(0.36),
emissiveIntensity: 0.26,
metalness: 0.04,
roughness: 0.28,
vertexColors: true
})
}
function createBounds(options: VoxelRendererOptions) {
const geometry = new THREE.EdgesGeometry(new THREE.BoxGeometry(WORLD_SIZE, WORLD_SIZE, WORLD_SIZE))
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(options.primaryColor).lerp(new THREE.Color('#ffffff'), 0.1)
})
const bounds = new THREE.LineSegments(geometry, material)
bounds.visible = options.showBounds
return bounds
}
function createGrid(options: VoxelRendererOptions) {
const grid = new THREE.GridHelper(
WORLD_SIZE,
12,
new THREE.Color(options.accentColor).lerp(new THREE.Color('#ffffff'), 0.05),
new THREE.Color(options.primaryColor).lerp(new THREE.Color('#000000'), 0.4)
)
grid.position.y = -WORLD_SIZE / 2
const material = grid.material as THREE.Material
material.opacity = 0.28
material.transparent = true
return grid
}
function updateSceneChrome(bundle: SceneBundle, options: VoxelRendererOptions) {
bundle.primaryColor = new THREE.Color(options.primaryColor)
bundle.accentColor = new THREE.Color(options.accentColor)
bundle.bounds.visible = options.showBounds
;(bundle.bounds.material as THREE.LineBasicMaterial).color = bundle.primaryColor.clone().lerp(new THREE.Color('#ffffff'), 0.1)
}
function createVoxelMesh(size: number, options: VoxelRendererOptions) {
const spacing = WORLD_SIZE / size
const edge = spacing * (1 - clamp(options.cellGap, 0.02, 0.45))
const geometry = new THREE.BoxGeometry(edge, edge, edge)
const material = createVoxelMaterial(new THREE.Color(options.primaryColor))
const mesh = new THREE.InstancedMesh(geometry, material, Math.max(1, size ** 3))
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
mesh.castShadow = true
mesh.receiveShadow = true
return mesh
}
function colorForCell(bundle: SceneBundle, cell: VoxelCell, size: number, ruleId: string, target: THREE.Color) {
const heightMix = size > 1 ? cell.y / (size - 1) : 0.5
const depthMix = size > 1 ? cell.z / (size - 1) : 0.5
const stateMix = clamp((cell.state ?? 1) / 6, 0, 1)
target.copy(bundle.primaryColor).lerp(bundle.accentColor, clamp(heightMix * 0.42 + stateMix * 0.5, 0, 1))
if (ruleId === 'lattice-gas-3d' || ruleId === 'snake-3d') {
target.lerp(new THREE.Color('#ffffff'), clamp(stateMix * 0.22 + depthMix * 0.08, 0, 0.34))
target.multiplyScalar(1 + stateMix * 0.16)
return
}
if (ruleId === 'generations-3d') {
target.lerp(new THREE.Color('#94a3b8'), stateMix * 0.22)
}
target.lerp(new THREE.Color('#ffffff'), clamp(depthMix * 0.18, 0, 0.24))
target.offsetHSL(0, 0, 0.05 + depthMix * 0.1)
}
function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number, ruleId: string, options: VoxelRendererOptions) {
disposeMesh(bundle.mesh)
bundle.mesh = createVoxelMesh(size, options)
bundle.scene.add(bundle.mesh)
const mesh = bundle.mesh
const dummy = new THREE.Object3D()
const color = new THREE.Color()
const spacing = WORLD_SIZE / size
const origin = -WORLD_SIZE / 2 + spacing / 2
let instanceIndex = 0
for (const cell of cells) {
if (instanceIndex >= mesh.count) break
if (cell.x < 0 || cell.y < 0 || cell.z < 0 || cell.x >= size || cell.y >= size || cell.z >= size) continue
dummy.position.set(origin + cell.x * spacing, origin + cell.y * spacing, origin + cell.z * spacing)
dummy.rotation.set(0, 0, 0)
dummy.updateMatrix()
colorForCell(bundle, cell, size, ruleId, color)
mesh.setMatrixAt(instanceIndex, dummy.matrix)
mesh.setColorAt(instanceIndex, color)
instanceIndex += 1
}
mesh.count = instanceIndex
mesh.instanceMatrix.needsUpdate = true
if (mesh.instanceColor) {
mesh.instanceColor.needsUpdate = true
}
}
export function createVoxelRendererCore(targetNode: HTMLDivElement, options: VoxelRendererOptions): VoxelRendererCore {
const scene = new THREE.Scene()
scene.background = new THREE.Color('#061006')
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 300)
camera.position.set(CAMERA_DISTANCE, CAMERA_HEIGHT, CAMERA_DISTANCE)
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.12
targetNode.appendChild(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.06
controls.minDistance = 12
controls.maxDistance = 60
controls.target.set(0, 0, 0)
scene.add(new THREE.AmbientLight('#f5efb7', 0.42))
scene.add(new THREE.HemisphereLight('#f5efb7', '#061006', 0.96))
const keyLight = new THREE.DirectionalLight('#fff3a8', 1.25)
keyLight.position.set(18, 28, 14)
keyLight.target.position.set(0, 0, 0)
scene.add(keyLight)
scene.add(keyLight.target)
const fillLight = new THREE.DirectionalLight(options.accentColor, 0.46)
fillLight.position.set(-24, 18, -12)
scene.add(fillLight)
const bounds = createBounds(options)
scene.add(bounds)
const grid = createGrid(options)
scene.add(grid)
const bundle: SceneBundle = {
animationFrame: 0,
bounds,
camera,
controls,
grid,
mesh: null,
mountNode: targetNode,
primaryColor: new THREE.Color(options.primaryColor),
accentColor: new THREE.Color(options.accentColor),
renderer,
resizeObserver: new ResizeObserver(() => resize()),
scene
}
function resize() {
const width = Math.max(targetNode.clientWidth, 1)
const height = Math.max(targetNode.clientHeight, 1)
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height, false)
}
function render() {
controls.update()
renderer.render(scene, camera)
}
function animate() {
render()
bundle.animationFrame = window.requestAnimationFrame(animate)
}
bundle.resizeObserver.observe(targetNode)
resize()
animate()
return {
dispose() {
window.cancelAnimationFrame(bundle.animationFrame)
bundle.resizeObserver.disconnect()
disposeMesh(bundle.mesh)
bundle.bounds.geometry.dispose()
disposeMaterial(bundle.bounds.material)
bundle.grid.geometry.dispose()
disposeMaterial(bundle.grid.material)
controls.dispose()
renderer.dispose()
if (renderer.domElement.parentNode === targetNode) {
targetNode.removeChild(renderer.domElement)
}
},
render,
update(input) {
updateSceneChrome(bundle, input.options)
updateVoxelMesh(bundle, input.cells, input.size, input.ruleId, input.options)
render()
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react()],
root: __dirname,
base: '/admin/',
resolve: {
dedupe: ['react', 'react-dom']
},
build: {
outDir: resolve(__dirname, '../public/admin'),
emptyOutDir: true
},
server: {
port: 5174,
proxy: {
'/api': 'http://localhost:3100'
}
}
})