Adding the lab project
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user