Merge pull request #314 from pascalorg/feat/node-registry-primitives

Node registry & plugin-ready architecture (Phase 0-5)
This commit is contained in:
Wassim SAMAD
2026-05-19 20:10:28 -04:00
committed by GitHub
323 changed files with 20228 additions and 12978 deletions
+1
View File
@@ -1,5 +1,6 @@
'use client'
import '../lib/bootstrap'
import {
applySceneGraphToEditor,
Editor,
+50
View File
@@ -0,0 +1,50 @@
import { discoverPlugins, loadPlugin, nodeRegistry } from '@pascal-app/core'
import { builtinPlugin } from '@pascal-app/nodes'
// Idempotency guard: HMR can reload this module, but `registerNode` throws on
// duplicate kinds. The flag lives in the module closure so it's reset on a
// hard reload but survives within a session.
let loaded = false
function isDev(): boolean {
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
?.env
return env?.NODE_ENV !== 'production'
}
export async function loadBuiltinNodes(): Promise<void> {
if (loaded) return
loaded = true
await loadPlugin(builtinPlugin)
// Phase 6 plugin discovery hook. Always called; default impl returns
// `[]`. Apps that ship external node packs override the discovery via
// `setPluginDiscovery(...)` before this module loads. See
// `wiki/editor-plugin-authoring.md` for the contract.
const externals = await discoverPlugins()
for (const plugin of externals) {
await loadPlugin(plugin)
}
if (isDev()) {
const kinds = Array.from(nodeRegistry.entries(), ([k]) => k)
if (typeof console !== 'undefined') {
// Visible in the browser dev console — the verification anchor for
// "which path is running this kind?" Empty array = every kind is on
// the legacy path. Kind in the array = registry path is live for it.
console.info(
`[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})${externals.length > 0 ? ` + ${externals.length} discovered plugin(s)` : ''}`,
)
}
// Expose the registry on window for ad-hoc dev inspection. In prod the
// registry is reachable through @pascal-app/core's exports only.
if (typeof globalThis !== 'undefined') {
;(globalThis as { __pascalNodeRegistry?: typeof nodeRegistry }).__pascalNodeRegistry =
nodeRegistry
}
}
}
// Run as a side effect on first import so any consumer of this module gets a
// populated registry without remembering to call the function explicitly.
void loadBuiltinNodes()
+1
View File
@@ -16,6 +16,7 @@
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7",
Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

+24
View File
@@ -122,6 +122,30 @@
}
}
}
},
{
"includes": [
"packages/core/**/*.ts",
"packages/core/**/*.tsx",
"packages/viewer/**/*.ts",
"packages/viewer/**/*.tsx",
"packages/editor/**/*.ts",
"packages/editor/**/*.tsx"
],
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"paths": {
"@pascal-app/nodes": "Framework packages must not import from @pascal-app/nodes — consult nodeRegistry.get(kind) instead. See plans/editor-node-registry.md."
}
}
}
}
}
}
}
]
}
+29
View File
@@ -31,6 +31,7 @@
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7",
@@ -182,6 +183,28 @@
"@pascal-app/core": "^0.8.0",
},
},
"packages/nodes": {
"name": "@pascal-app/nodes",
"version": "0.1.0",
"devDependencies": {
"@pascal-app/core": "^0.8.0",
"@pascal-app/viewer": "^0.8.0",
"@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
"typescript": "6.0.2",
},
"peerDependencies": {
"@pascal-app/core": "^0.8.0",
"@pascal-app/viewer": "^0.8.0",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.184",
},
},
"packages/typescript-config": {
"name": "@repo/typescript-config",
"version": "0.0.0",
@@ -451,6 +474,8 @@
"@pascal-app/mcp": ["@pascal-app/mcp@workspace:packages/mcp"],
"@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"],
"@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"],
"@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"],
@@ -1531,6 +1556,8 @@
"@pascal-app/mcp/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@pascal-app/nodes/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="],
"@pascal-app/viewer/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -1613,6 +1640,8 @@
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@pascal-app/nodes/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@pascal-app/viewer/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@repo/ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+7
View File
@@ -16,6 +16,11 @@
"import": "./dist/utils/clone-scene-graph.js",
"default": "./dist/utils/clone-scene-graph.js"
},
"./registry": {
"types": "./dist/registry/index.d.ts",
"import": "./dist/registry/index.js",
"default": "./dist/registry/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
@@ -54,6 +59,8 @@
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch",
"test": "bun test",
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
+16 -1
View File
@@ -13,6 +13,8 @@ import type {
LevelNode,
RoofNode,
RoofSegmentNode,
ScanNode,
ShelfNode,
SiteNode,
SlabNode,
SpawnNode,
@@ -35,7 +37,14 @@ export interface GridEvent {
*/
localPosition: [number, number, number]
faceIndex?: number
object: Object3D
/**
* Optional: the hit Three.js object. Present when the grid event was
* synthesized from a R3F mesh hit (the legacy grid-plane mesh path);
* absent when emitted by the canvas-level raycaster in
* `use-grid-events.ts`, where there is no specific mesh to attribute
* the intersection to.
*/
object?: Object3D
nativeEvent: ThreeEvent<PointerEvent>
}
@@ -57,6 +66,7 @@ export type SiteEvent = NodeEvent<SiteNode>
export type BuildingEvent = NodeEvent<BuildingNode>
export type LevelEvent = NodeEvent<LevelNode>
export type ZoneEvent = NodeEvent<ZoneNode>
export type ShelfEvent = NodeEvent<ShelfNode>
export type SlabEvent = NodeEvent<SlabNode>
export type SpawnEvent = NodeEvent<SpawnNode>
export type CeilingEvent = NodeEvent<CeilingNode>
@@ -68,6 +78,8 @@ export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
export type ElevatorEvent = NodeEvent<ElevatorNode>
export type ScanEvent = NodeEvent<ScanNode>
export type GuideEvent = NodeEvent<GuideNode>
// Event suffixes - exported for use in hooks
export const eventSuffixes = [
@@ -189,6 +201,7 @@ type EditorEvents = GridEvents &
NodeEvents<'level', LevelEvent> &
NodeEvents<'zone', ZoneEvent> &
NodeEvents<'slab', SlabEvent> &
NodeEvents<'shelf', ShelfEvent> &
NodeEvents<'spawn', SpawnEvent> &
NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'column', ColumnEvent> &
@@ -198,6 +211,8 @@ type EditorEvents = GridEvents &
NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> &
NodeEvents<'scan', ScanEvent> &
NodeEvents<'guide', GuideEvent> &
CameraControlEvents &
ToolEvents &
GuideEvents &
@@ -3,49 +3,61 @@
import { useLayoutEffect } from 'react'
import type * as THREE from 'three'
// `byType` is a Proxy-backed Map keyed by kind. Sets are created lazily on
// first access, so any kind (built-in or plugin-contributed) participates
// without needing a hardcoded seed list. The previous `KNOWN_NODE_KINDS`
// array was a pre-seed for autocomplete; with every kind now flowing
// through `nodeRegistry`, the seed is redundant.
//
// The type expresses that *any* string key returns a `Set<string>` — the
// Proxy auto-creates on first access so there's no `undefined` branch at
// runtime. Without this shape, `noUncheckedIndexedAccess` would force
// every caller to defend against an impossible undefined.
type ByTypeMap = { [kind: string]: Set<string> }
const byTypeStore = new Map<string, Set<string>>()
const byTypeProxy = new Proxy({} as ByTypeMap, {
get(_target, key) {
if (typeof key !== 'string') return undefined
let set = byTypeStore.get(key)
if (!set) {
set = new Set<string>()
byTypeStore.set(key, set)
}
return set
},
ownKeys() {
return Array.from(byTypeStore.keys())
},
has(_target, key) {
return typeof key === 'string' && byTypeStore.has(key)
},
getOwnPropertyDescriptor(_target, key) {
if (typeof key !== 'string') return undefined
const set = byTypeStore.get(key)
if (!set) return undefined
return { configurable: true, enumerable: true, value: set, writable: false }
},
})
export const sceneRegistry = {
// Master lookup: ID -> Object3D
nodes: new Map<string, THREE.Object3D>(),
// Categorized lookups: Type -> Set of IDs
// Using a Set is faster for adding/deleting than an Array
byType: {
site: new Set<string>(),
building: new Set<string>(),
ceiling: new Set<string>(),
column: new Set<string>(),
elevator: new Set<string>(),
level: new Set<string>(),
wall: new Set<string>(),
fence: new Set<string>(),
item: new Set<string>(),
slab: new Set<string>(),
spawn: new Set<string>(),
zone: new Set<string>(),
roof: new Set<string>(),
'roof-segment': new Set<string>(),
stair: new Set<string>(),
'stair-segment': new Set<string>(),
scan: new Set<string>(),
guide: new Set<string>(),
window: new Set<string>(),
door: new Set<string>(),
},
// Categorized lookups: Kind -> Set of IDs. Backed by a Proxy so any kind
// gets a Set on first touch — no hardcoded list.
byType: byTypeProxy,
/** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */
clear() {
this.nodes.clear()
for (const set of Object.values(this.byType)) {
for (const set of byTypeStore.values()) {
set.clear()
}
},
}
export function useRegistry(
id: string,
type: keyof typeof sceneRegistry.byType,
ref: React.RefObject<THREE.Object3D>,
) {
export function useRegistry(id: string, type: string, ref: React.RefObject<THREE.Object3D>) {
useLayoutEffect(() => {
const obj = ref.current
if (!obj) return
@@ -53,13 +65,13 @@ export function useRegistry(
// 1. Add to master map
sceneRegistry.nodes.set(id, obj)
// 2. Add to type-specific set
sceneRegistry.byType[type].add(id)
// 2. Add to type-specific set — Proxy auto-creates on first access.
sceneRegistry.byType[type]!.add(id)
// 4. Cleanup when component unmounts
// 3. Cleanup when component unmounts
return () => {
sceneRegistry.nodes.delete(id)
sceneRegistry.byType[type].delete(id)
sceneRegistry.byType[type]!.delete(id)
}
}, [id, type, ref])
}
@@ -1,11 +1,5 @@
import {
type AnyNode,
type AnyNodeId,
getScaledDimensions,
type ItemNode,
type SlabNode,
type WallNode,
} from '../../schema'
import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
import useScene from '../../store/use-scene'
import {
itemOverlapsPolygon,
@@ -135,31 +129,35 @@ function markNodesOverlappingSlab(
const slabLevelId = resolveLevelId(slab, nodes)
for (const node of Object.values(nodes)) {
if (node.type === 'item') {
const item = node as ItemNode
// Only floor items are affected by slabs
if (item.asset.attachTo) continue
if (resolveLevelId(node, nodes) !== slabLevelId) continue
if (
itemOverlapsPolygon(
item.position,
getScaledDimensions(item),
item.rotation,
slab.polygon,
0.01,
)
) {
markDirty(node.id)
}
} else if (node.type === 'wall') {
if (node.type === 'wall') {
const wall = node as WallNode
if (resolveLevelId(node, nodes) !== slabLevelId) continue
if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) {
markDirty(node.id)
}
} else if (node.type === 'stair') {
continue
}
if (node.type === 'stair') {
if (resolveLevelId(node, nodes) !== slabLevelId) continue
markDirty(node.id)
continue
}
// Generic floor-placed sweep: any registry kind that opts in via
// `capabilities.floorPlaced` (item / shelf / column / spawn / …)
// re-elevates through `<FloorElevationSystem>` when a slab below
// changes. We dirty-mark when the kind's footprint overlaps the
// changed slab so the system picks it up next frame.
const def = nodeRegistry.get(node.type)
const floorPlaced = def?.capabilities?.floorPlaced
if (!floorPlaced) continue
if (floorPlaced.applies && !floorPlaced.applies(node)) continue
if (resolveLevelId(node, nodes) !== slabLevelId) continue
const position = (node as { position?: [number, number, number] }).position
if (!position) continue
const { dimensions, rotation } = floorPlaced.footprint(node)
if (itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
markDirty(node.id)
}
}
}
+8 -3
View File
@@ -9,11 +9,14 @@ export type {
EventSuffix,
FenceEvent,
GridEvent,
GuideEvent,
ItemEvent,
LevelEvent,
NodeEvent,
RoofEvent,
RoofSegmentEvent,
ScanEvent,
ShelfEvent,
SiteEvent,
SlabEvent,
SpawnEvent,
@@ -44,10 +47,10 @@ export {
} from './lib/door-operation'
export { getRenderableSlabPolygon } from './lib/slab-polygon'
export {
type AutoSlabSyncPlan,
detectSpacesForLevel,
initSpaceDetectionSync,
planAutoSlabsForLevel,
type AutoSlabSyncPlan,
type Space,
wallTouchesOthers,
} from './lib/space-detection'
@@ -63,7 +66,9 @@ export {
type MaterialCategory,
toLibraryMaterialRef,
} from './material-library'
export * from './registry'
export * from './schema'
export * from './services'
export {
getSceneHistoryPauseDepth,
pauseSceneHistory,
@@ -89,6 +94,7 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li
export { clearSceneHistory, default as useScene } from './store/use-scene'
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
export {
type ElevatorDoorSide,
getElevatorCabCenterZ,
getElevatorCabDepth,
getElevatorCabWidth,
@@ -101,7 +107,6 @@ export {
getResolvedElevatorDoorPanelStyle,
getResolvedElevatorDoorStyle,
getResolvedElevatorShaftStyle,
type ElevatorDoorSide,
} from './systems/elevator/elevator-geometry'
export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync'
export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system'
@@ -157,8 +162,8 @@ export {
constrainWallMoveDeltaToAxis,
getPerpendicularWallMoveAxis,
planWallMoveJunctions,
type WallMoveBridgePlan,
type WallMoveAxis,
type WallMoveBridgePlan,
type WallMoveJunctionPlan,
type WallPlanPoint,
} from './systems/wall/wall-move'
@@ -0,0 +1,190 @@
/**
* Bench harness for the relations cascade resolver.
*
* Phase 1 risk gate: at 5000 nodes the resolver step must stay under
* 2ms p95 per `cascadeDirty` invocation. Above that, the registry-driven
* dispatch will tank framerate during a corner drag in Phase 3.
*
* Run via:
* bun run packages/core/src/registry/__bench__/relations-resolver.bench.ts
*
* Output: JSON to stdout with { p50, p95, p99, mean, max, n } in milliseconds.
* Doubles as a regression gate — wire into CI when we have a baseline.
*/
import { z } from 'zod'
import type { AnyNode, AnyNodeId } from '../../schema/types'
import { nodeRegistry, registerNode } from '../registry'
import { cascadeDirty, type SpatialQuery } from '../relations-resolver'
import type { AnyNodeDefinition, SceneApi } from '../types'
const ID = (s: string) => s as AnyNodeId
function makeDef(kind: string, relations?: AnyNodeDefinition['relations']): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities: {},
relations,
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
}
}
/**
* Builds a 5000-node fixture: a 50×100 grid of walls (so 5000 walls).
* Each wall hosts up to 2 doors and is bordered by ~4 slabs (in a sparse
* spatial index). Designed to stress hosts + affectsSpatial cascade
* simultaneously.
*
* Returns:
* - 5000 wall nodes
* - 8000 door nodes (children of walls)
* - 200 slab nodes (sparse; ~25 walls per slab)
*
* Total: ~13,200 nodes. The cascade starts from one wall and should mark
* its children (doors) + its spatial neighbors (slabs) dirty.
*/
function buildFixture() {
const nodes: Record<string, AnyNode> = {}
const wallToSlabIds = new Map<string, AnyNodeId[]>()
for (let row = 0; row < 50; row++) {
for (let col = 0; col < 100; col++) {
const wallId = ID(`wall_r${row}c${col}`)
const childIds: AnyNodeId[] = []
for (let d = 0; d < 2; d++) {
const doorId = ID(`door_r${row}c${col}d${d}`)
childIds.push(doorId)
nodes[doorId as string] = {
id: doorId,
type: 'door',
parentId: wallId,
visible: true,
} as unknown as AnyNode
}
nodes[wallId as string] = {
id: wallId,
type: 'wall',
parentId: null,
visible: true,
children: childIds,
} as unknown as AnyNode
// Map this wall to its bordering slab (sparse: ~25 walls share a slab).
const slabRow = Math.floor(row / 5)
const slabCol = Math.floor(col / 5)
const slabId = ID(`slab_r${slabRow}c${slabCol}`)
const list = wallToSlabIds.get(wallId as string) ?? []
list.push(slabId)
wallToSlabIds.set(wallId as string, list)
}
}
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 20; col++) {
const slabId = ID(`slab_r${row}c${col}`)
nodes[slabId as string] = {
id: slabId,
type: 'slab',
parentId: null,
visible: true,
} as unknown as AnyNode
}
}
return { nodes, wallToSlabIds }
}
function makeScene(nodes: Record<string, AnyNode>): SceneApi {
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
update: () => {},
upsert: () => ID(''),
delete: () => {},
restore: () => {},
restoreAll: () => {},
markDirty: () => {},
pauseHistory: () => {},
resumeHistory: () => {},
}
}
function percentile(values: number[], p: number): number {
const sorted = [...values].sort((a, b) => a - b)
const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))
return sorted[idx] ?? 0
}
async function main() {
nodeRegistry._reset()
registerNode(makeDef('wall', { hosts: ['door'], affectsSpatial: ['slab'] }))
registerNode(makeDef('door'))
registerNode(makeDef('slab'))
const { nodes, wallToSlabIds } = buildFixture()
const scene = makeScene(nodes)
const spatialQuery: SpatialQuery = (node, kinds) => {
if (!kinds.includes('slab')) return []
return wallToSlabIds.get(node.id as string) ?? []
}
const totalNodes = Object.keys(nodes).length
const totalWalls = 50 * 100
console.log(`[bench] fixture: ${totalNodes} nodes (${totalWalls} walls, 8000 doors, 200 slabs)`)
const iterations = 1000
const samples: number[] = []
// Warm-up — JIT, cache lines, etc.
for (let i = 0; i < 50; i++) {
cascadeDirty(ID(`wall_r${i % 50}c${i % 100}`), { scene, spatialQuery })
}
for (let i = 0; i < iterations; i++) {
const row = i % 50
const col = i % 100
const startId = ID(`wall_r${row}c${col}`)
const t0 = performance.now()
cascadeDirty(startId, { scene, spatialQuery })
const elapsed = performance.now() - t0
samples.push(elapsed)
}
const mean = samples.reduce((acc, v) => acc + v, 0) / samples.length
const max = Math.max(...samples)
const p50 = percentile(samples, 50)
const p95 = percentile(samples, 95)
const p99 = percentile(samples, 99)
const result = {
fixture: { totalNodes, walls: totalWalls, doors: 8000, slabs: 200 },
iterations,
p50_ms: Number(p50.toFixed(4)),
p95_ms: Number(p95.toFixed(4)),
p99_ms: Number(p99.toFixed(4)),
mean_ms: Number(mean.toFixed(4)),
max_ms: Number(max.toFixed(4)),
}
console.log(JSON.stringify(result, null, 2))
const target = 2.0
if (p95 > target) {
console.error(
`\n❌ p95 ${p95.toFixed(2)}ms exceeds Phase 1 gate of ${target}ms. ` +
`Phase 2 (column + shelf) can still proceed since their relations are empty, ` +
`but Phase 3 wall migration must add spatial-index-backed neighbor queries first.`,
)
process.exitCode = 1
} else {
console.log(`\n✅ p95 ${p95.toFixed(3)}ms within Phase 1 gate of ${target}ms`)
}
}
main().catch((err) => {
console.error(err)
process.exitCode = 1
})
+69
View File
@@ -0,0 +1,69 @@
export {
discoverPlugins,
getSelectableKinds,
isRegistrySelectable,
loadPlugin,
nodeRegistry,
type PluginDiscovery,
registerNode,
setPluginDiscovery,
} from './registry'
export {
type CascadeContext,
type ChildQuery,
cascadeDirty,
collectDescendants,
type SpatialQuery,
} from './relations-resolver'
export { createSceneApi, type SceneStoreLike } from './scene-api'
export type {
Affordance,
AnyNodeDefinition,
AssetRef,
Capabilities,
CapabilityCtx,
CuttableConfig,
DragAction,
EditorCtx,
FloorplanAffordance,
FloorplanAffordanceModifiers,
FloorplanAffordancePoint,
FloorplanAffordanceSession,
FloorplanGeometry,
FloorplanMoveTarget,
FloorplanMoveTargetSession,
FloorplanPalette,
FloorplanPoint,
FloorplanStyle,
GeometryContext,
HostableConfig,
IconRef,
Issue,
LazyComponent,
McpOverrides,
Modifiers,
MovableConfig,
NodeCategory,
NodeDefinition,
NodeRegistry,
ParametricDescriptor,
ParamField,
ParamGroup,
Plugin,
Presentation,
Relations,
RendererSource,
RotatableConfig,
ScalableConfig,
SceneApi,
SelectableConfig,
SnapPointKind,
SnappableConfig,
SnapServicesLike,
SurfacePoint,
SurfaceQuery,
SurfacesConfig,
SystemContribution,
ToolHint,
Vec2,
} from './types'
+124
View File
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { loadPlugin, nodeRegistry, registerNode } from './registry'
import type { AnyNodeDefinition, Plugin } from './types'
function makeDefinition(
kind: string,
overrides: Partial<AnyNodeDefinition> = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities: {},
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
...overrides,
}
}
describe('nodeRegistry', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('starts empty', () => {
expect(nodeRegistry.size).toBe(0)
expect(nodeRegistry.has('anything')).toBe(false)
expect(nodeRegistry.get('anything')).toBeUndefined()
})
test('registerNode adds a definition', () => {
const def = makeDefinition('column')
registerNode(def)
expect(nodeRegistry.size).toBe(1)
expect(nodeRegistry.has('column')).toBe(true)
expect(nodeRegistry.get('column')).toBe(def)
})
test('registerNode throws on duplicate kind', () => {
registerNode(makeDefinition('column'))
expect(() => registerNode(makeDefinition('column'))).toThrow(/duplicate node kind/)
})
test('registerNode rejects empty kind', () => {
expect(() => registerNode(makeDefinition(''))).toThrow(/non-empty string/)
})
test('registerNode rejects invalid schemaVersion', () => {
expect(() => registerNode(makeDefinition('bad', { schemaVersion: 0 }))).toThrow(/schemaVersion/)
expect(() => registerNode(makeDefinition('bad', { schemaVersion: -1 }))).toThrow(
/schemaVersion/,
)
})
test('entries() iterates registered definitions', () => {
registerNode(makeDefinition('a'))
registerNode(makeDefinition('b'))
const kinds = Array.from(nodeRegistry.entries(), ([k]) => k)
expect(kinds).toEqual(['a', 'b'])
})
test('schemas() returns all registered schemas', () => {
const a = makeDefinition('a')
const b = makeDefinition('b')
registerNode(a)
registerNode(b)
expect(nodeRegistry.schemas()).toEqual([a.schema, b.schema])
})
})
describe('loadPlugin', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('registers all nodes from a plugin', async () => {
const plugin: Plugin = {
id: 'test:plugin',
apiVersion: 1,
nodes: [makeDefinition('a'), makeDefinition('b')],
}
await loadPlugin(plugin)
expect(nodeRegistry.size).toBe(2)
expect(nodeRegistry.has('a')).toBe(true)
expect(nodeRegistry.has('b')).toBe(true)
})
test('handles plugin with no nodes', async () => {
await loadPlugin({ id: 'empty', apiVersion: 1 })
expect(nodeRegistry.size).toBe(0)
})
test('handles plugin with empty nodes array', async () => {
await loadPlugin({ id: 'empty', apiVersion: 1, nodes: [] })
expect(nodeRegistry.size).toBe(0)
})
test('throws on apiVersion mismatch', async () => {
const plugin = {
id: 'old-plugin',
apiVersion: 99 as unknown as 1,
nodes: [],
}
await expect(loadPlugin(plugin)).rejects.toThrow(/apiVersion/)
})
test('propagates duplicate-kind error from a single plugin', async () => {
const plugin: Plugin = {
id: 'broken',
apiVersion: 1,
nodes: [makeDefinition('dup'), makeDefinition('dup')],
}
await expect(loadPlugin(plugin)).rejects.toThrow(/duplicate node kind/)
})
test('propagates duplicate-kind error across plugins', async () => {
await loadPlugin({ id: 'a', apiVersion: 1, nodes: [makeDefinition('shared')] })
await expect(
loadPlugin({ id: 'b', apiVersion: 1, nodes: [makeDefinition('shared')] }),
).rejects.toThrow(/duplicate node kind/)
})
})
+135
View File
@@ -0,0 +1,135 @@
import type { ZodObject } from 'zod'
import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types'
const HOST_API_VERSION = 1 as const
class NodeRegistryImpl implements NodeRegistry {
private readonly defs = new Map<string, AnyNodeDefinition>()
has(kind: string): boolean {
return this.defs.has(kind)
}
get(kind: string): AnyNodeDefinition | undefined {
return this.defs.get(kind)
}
entries(): IterableIterator<[string, AnyNodeDefinition]> {
return this.defs.entries()
}
schemas(): ZodObject<any>[] {
return Array.from(this.defs.values(), (d) => d.schema)
}
get size(): number {
return this.defs.size
}
// Internal — exposed via registerNode below.
_register(def: AnyNodeDefinition): void {
if (this.defs.has(def.kind)) {
throw new Error(`[registry] duplicate node kind: "${def.kind}" already registered`)
}
if (typeof def.kind !== 'string' || def.kind.length === 0) {
throw new Error('[registry] NodeDefinition.kind must be a non-empty string')
}
if (typeof def.schemaVersion !== 'number' || def.schemaVersion < 1) {
throw new Error(
`[registry] NodeDefinition.schemaVersion must be a positive integer (kind: "${def.kind}")`,
)
}
this.defs.set(def.kind, def)
}
// Test-only — clears the registry. Not exported from the package barrel.
_reset(): void {
this.defs.clear()
}
}
export const nodeRegistry: NodeRegistry & {
_register: (def: AnyNodeDefinition) => void
_reset: () => void
} = new NodeRegistryImpl()
export function registerNode(def: AnyNodeDefinition): void {
nodeRegistry._register(def)
}
/**
* Returns the set of registered kinds whose definition declares the
* `selectable` capability. Callers that maintain hardcoded "selectable kinds"
* lists (SelectionManager, FloatingActionMenu) should concat this with their
* legacy entries instead of editing the hardcoded list per migration.
*
* Phase 6 deletes the hardcoded lists entirely and uses this function as the
* single source of truth. For now it's additive over the legacy lists so the
* existing kinds keep working unchanged.
*/
export function getSelectableKinds(): string[] {
const result: string[] = []
for (const [kind, def] of nodeRegistry.entries()) {
if (def.capabilities.selectable !== undefined) {
result.push(kind)
}
}
return result
}
/**
* Returns true when the kind is declared selectable in the registry. Use
* in expression chains like `if (node.type === 'wall' || isRegistrySelectable(node.type))`.
*/
export function isRegistrySelectable(kind: string): boolean {
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined
}
export async function loadPlugin(plugin: Plugin): Promise<void> {
if (plugin.apiVersion !== HOST_API_VERSION) {
throw new Error(
`[registry] plugin "${plugin.id}" requires apiVersion ${plugin.apiVersion}; host supports ${HOST_API_VERSION}`,
)
}
for (const def of plugin.nodes ?? []) {
registerNode(def)
}
}
/**
* App-level plugin discovery hook. The bootstrap loads `builtinPlugin`
* unconditionally and then awaits this to pick up any extra plugins
* (third-party node packs, AI-authored bundles, user-installed kinds).
* Defaults to returning `[]` — apps that want external plugins call
* {@link setPluginDiscovery} before the bootstrap module runs.
*
* Kept async so a future loader can fetch over the network without
* changing the contract. See `wiki/editor-plugin-authoring.md` for the
* plugin author surface this enables.
*/
export type PluginDiscovery = () => Promise<Plugin[]>
let pluginDiscovery: PluginDiscovery = async () => []
/**
* Replace the plugin discovery implementation. Call once at app startup
* before {@link discoverPlugins} is invoked (bootstrap order matters).
*
* The contract is intentionally minimal — just "return a list of
* plugins to load." The loader can be a static `import.meta.glob`, a
* `fetch` against a registry endpoint, a worker IPC, etc. Each returned
* plugin still goes through {@link loadPlugin} so the same API-version
* gate + duplicate-kind protection applies.
*/
export function setPluginDiscovery(fn: PluginDiscovery): void {
pluginDiscovery = fn
}
/**
* Run the active plugin discovery and return the discovered plugins.
* Bootstrap code is expected to call this after `loadPlugin(builtinPlugin)`
* and then `await loadPlugin(...)` each result in order.
*/
export function discoverPlugins(): Promise<Plugin[]> {
return pluginDiscovery()
}
@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { nodeRegistry, registerNode } from './registry'
import { cascadeDirty, collectDescendants, type SpatialQuery } from './relations-resolver'
import type { AnyNodeDefinition, Relations, SceneApi } from './types'
const id = (s: string) => s as AnyNodeId
function makeDef(
kind: string,
relations?: Relations,
overrides: Partial<AnyNodeDefinition> = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities: {},
relations,
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
...overrides,
}
}
function makeNode(kind: string, idStr: string, extra: Partial<AnyNode> = {}): AnyNode {
return {
id: id(idStr),
type: kind,
parentId: null,
visible: true,
...extra,
} as unknown as AnyNode
}
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
update: () => {},
upsert: () => id(''),
delete: () => {},
restore: () => {},
restoreAll: () => {},
markDirty: () => {},
pauseHistory: () => {},
resumeHistory: () => {},
}
}
describe('cascadeDirty', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('starting node alone when registry is empty', () => {
const scene = makeFakeScene({ a: makeNode('unknown', 'a') })
const dirty = cascadeDirty(id('a'), { scene })
expect(Array.from(dirty)).toEqual([id('a')])
})
test('starting node alone when no relations declared', () => {
registerNode(makeDef('thing'))
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
const dirty = cascadeDirty(id('a'), { scene })
expect(Array.from(dirty)).toEqual([id('a')])
})
test('hosts cascade marks matching children dirty', () => {
registerNode(makeDef('wall', { hosts: ['door', 'window'] }))
registerNode(makeDef('door'))
registerNode(makeDef('window'))
registerNode(makeDef('lamp'))
const wall = makeNode('wall', 'w1', {
children: [id('d1'), id('w2'), id('l1')],
} as Partial<AnyNode>)
const scene = makeFakeScene({
w1: wall,
d1: makeNode('door', 'd1', { parentId: id('w1') }),
w2: makeNode('window', 'w2', { parentId: id('w1') }),
l1: makeNode('lamp', 'l1', { parentId: id('w1') }), // not in hosts list
})
const dirty = cascadeDirty(id('w1'), { scene })
const ids = Array.from(dirty).sort()
expect(ids).toEqual([id('d1'), id('w1'), id('w2')]) // l1 excluded
})
test('hosts cascade is recursive but bounded by maxDepth', () => {
registerNode(makeDef('a', { hosts: ['a'] })) // a hosts more a
const nodes: Record<string, AnyNode> = {}
for (let i = 0; i < 25; i++) {
const childId = i < 24 ? id(`a${i + 1}`) : undefined
nodes[`a${i}`] = makeNode('a', `a${i}`, {
children: childId ? [childId] : [],
} as Partial<AnyNode>)
}
const scene = makeFakeScene(nodes)
const dirty = cascadeDirty(id('a0'), { scene, maxDepth: 5 })
expect(dirty.size).toBe(6) // a0 + 5 descendants
})
test('affectsSpatial cascade uses spatialQuery to find neighbors', () => {
registerNode(makeDef('wall', { affectsSpatial: ['slab', 'zone'] }))
registerNode(makeDef('slab'))
registerNode(makeDef('zone'))
const scene = makeFakeScene({
w1: makeNode('wall', 'w1'),
s1: makeNode('slab', 's1'),
z1: makeNode('zone', 'z1'),
unrelated: makeNode('door', 'unrelated'),
})
const spatialQuery: SpatialQuery = (node, kinds) => {
if (node.id !== id('w1')) return []
const matches: AnyNodeId[] = []
if (kinds.includes('slab')) matches.push(id('s1'))
if (kinds.includes('zone')) matches.push(id('z1'))
return matches
}
const dirty = cascadeDirty(id('w1'), { scene, spatialQuery })
expect(Array.from(dirty).sort()).toEqual([id('s1'), id('w1'), id('z1')])
})
test('affectsSpatial is a no-op when no spatialQuery is provided', () => {
registerNode(makeDef('wall', { affectsSpatial: ['slab'] }))
const scene = makeFakeScene({ w1: makeNode('wall', 'w1') })
const dirty = cascadeDirty(id('w1'), { scene })
expect(Array.from(dirty)).toEqual([id('w1')]) // spatial branch silently skipped
})
test('cycle in hosts cascade does not loop forever', () => {
registerNode(makeDef('a', { hosts: ['a'] }))
const nodes: Record<string, AnyNode> = {
a1: makeNode('a', 'a1', { children: [id('a2')] } as Partial<AnyNode>),
a2: makeNode('a', 'a2', { children: [id('a1')] } as Partial<AnyNode>), // cycle
}
const scene = makeFakeScene(nodes)
const dirty = cascadeDirty(id('a1'), { scene })
expect(dirty.size).toBe(2)
expect(dirty.has(id('a1'))).toBe(true)
expect(dirty.has(id('a2'))).toBe(true)
})
test('custom childQuery overrides the default node.children lookup', () => {
registerNode(makeDef('wall', { hosts: ['door'] }))
registerNode(makeDef('door'))
const scene = makeFakeScene({
w1: makeNode('wall', 'w1'), // no children field
d1: makeNode('door', 'd1', { parentId: id('w1') }),
})
// childQuery iterates the scene to find parentId matches — what you would
// do for kinds that don't carry an explicit children array.
const childQuery = (node: AnyNode) => {
const result: AnyNodeId[] = []
for (const candidate of [id('d1')]) {
const c = scene.get(candidate)
if (c && c.parentId === node.id) result.push(c.id)
}
return result
}
const dirty = cascadeDirty(id('w1'), { scene, childQuery })
expect(Array.from(dirty).sort()).toEqual([id('d1'), id('w1')])
})
})
describe('collectDescendants', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('returns just the start when no children', () => {
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
const result = collectDescendants(id('a'), { scene })
expect(Array.from(result)).toEqual([id('a')])
})
test('returns full subtree regardless of relations declarations', () => {
// No def registered — descendants still found via the children array.
const scene = makeFakeScene({
root: makeNode('thing', 'root', { children: [id('c1'), id('c2')] } as Partial<AnyNode>),
c1: makeNode('thing', 'c1', { children: [id('g1')] } as Partial<AnyNode>),
c2: makeNode('thing', 'c2'),
g1: makeNode('thing', 'g1'),
})
const result = collectDescendants(id('root'), { scene })
expect(Array.from(result).sort()).toEqual([id('c1'), id('c2'), id('g1'), id('root')])
})
test('respects maxDepth', () => {
const scene = makeFakeScene({
a: makeNode('thing', 'a', { children: [id('b')] } as Partial<AnyNode>),
b: makeNode('thing', 'b', { children: [id('c')] } as Partial<AnyNode>),
c: makeNode('thing', 'c', { children: [id('d')] } as Partial<AnyNode>),
d: makeNode('thing', 'd'),
})
const result = collectDescendants(id('a'), { scene, maxDepth: 2 })
expect(Array.from(result).sort()).toEqual([id('a'), id('b'), id('c')]) // d truncated
})
})
@@ -0,0 +1,124 @@
import type { AnyNode, AnyNodeId } from '../schema/types'
import { nodeRegistry } from './registry'
import type { SceneApi } from './types'
/**
* Spatial neighbor query — given a node and a set of kinds, returns IDs of
* neighboring nodes of those kinds. The runtime provides this from
* `spatialGridManager`; tests can pass a stub.
*/
export type SpatialQuery = (node: AnyNode, kinds: readonly string[]) => Iterable<AnyNodeId>
/**
* Returns the IDs of nodes that share `node` as a parent. The runtime can
* pass an optimized index; the default fallback iterates the scene.
*/
export type ChildQuery = (node: AnyNode) => Iterable<AnyNodeId>
export type CascadeContext = {
scene: SceneApi
/** Optional: bounded spatial neighbor lookup. Required for `affectsSpatial`. */
spatialQuery?: SpatialQuery
/** Optional: children-by-parent lookup. Defaults to iterating the scene. */
childQuery?: ChildQuery
/** Safety cap on cascade depth — guards against bad data and pathological
* registry configurations. Default 16 (deeper than the maxHostDepth of 6). */
maxDepth?: number
}
const DEFAULT_MAX_DEPTH = 16
/**
* Walks the relations graph from one dirty node and returns the full set of
* IDs (including the starting one) that should be marked dirty. Pure — does
* NOT call `scene.markDirty`; callers iterate the result.
*
* Phase 1 implements:
* - `hosts`: marks children whose `type` matches the kind list
* - `affectsSpatial`: marks neighbors found via `spatialQuery`
*
* Phase 3 will add `linkedBy: 'endpoint-match'` for wall corner propagation.
*/
export function cascadeDirty(startId: AnyNodeId, ctx: CascadeContext): Set<AnyNodeId> {
const result = new Set<AnyNodeId>()
const maxDepth = ctx.maxDepth ?? DEFAULT_MAX_DEPTH
walk(startId, ctx, result, 0, maxDepth)
return result
}
function walk(
id: AnyNodeId,
ctx: CascadeContext,
result: Set<AnyNodeId>,
depth: number,
maxDepth: number,
): void {
if (result.has(id) || depth > maxDepth) return
result.add(id)
const node = ctx.scene.get(id)
if (!node) return
const def = nodeRegistry.get(node.type)
if (!def?.relations) return
const { hosts, affectsSpatial } = def.relations
if (hosts && hosts.length > 0) {
const childIds = ctx.childQuery ? ctx.childQuery(node) : defaultChildIds(node, ctx.scene)
for (const childId of childIds) {
const child = ctx.scene.get(childId)
if (child && (hosts as readonly string[]).includes(child.type)) {
walk(childId, ctx, result, depth + 1, maxDepth)
}
}
}
if (affectsSpatial && affectsSpatial.length > 0 && ctx.spatialQuery) {
for (const neighborId of ctx.spatialQuery(node, affectsSpatial)) {
walk(neighborId, ctx, result, depth + 1, maxDepth)
}
}
}
/**
* Fallback children lookup that reads the node's `children: AnyNodeId[]`
* field if present. Most parametric nodes carry one; nodes that don't will
* need a `childQuery` override on the context.
*/
function defaultChildIds(node: AnyNode, _scene: SceneApi): AnyNodeId[] {
const maybeChildren = (node as unknown as { children?: AnyNodeId[] }).children
return Array.isArray(maybeChildren) ? maybeChildren : []
}
/**
* Recursively collects every descendant of a node, plus the node itself.
* Used by `cascadeDelete: 'descendants'` and by tools that need to delete a
* subtree atomically. Independent of dirty-marking — pure traversal.
*/
export function collectDescendants(
startId: AnyNodeId,
ctx: Pick<CascadeContext, 'scene' | 'childQuery' | 'maxDepth'>,
): Set<AnyNodeId> {
const result = new Set<AnyNodeId>()
const maxDepth = ctx.maxDepth ?? DEFAULT_MAX_DEPTH
walkDescendants(startId, ctx, result, 0, maxDepth)
return result
}
function walkDescendants(
id: AnyNodeId,
ctx: Pick<CascadeContext, 'scene' | 'childQuery' | 'maxDepth'>,
result: Set<AnyNodeId>,
depth: number,
maxDepth: number,
): void {
if (result.has(id) || depth > maxDepth) return
result.add(id)
const node = ctx.scene.get(id)
if (!node) return
const childIds = ctx.childQuery ? ctx.childQuery(node) : defaultChildIds(node, ctx.scene)
for (const childId of childIds) {
walkDescendants(childId, ctx, result, depth + 1, maxDepth)
}
}
@@ -0,0 +1,205 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { resetSceneHistoryPauseDepth } from '../store/history-control'
import { createSceneApi, type SceneStoreLike } from './scene-api'
function makeFakeStore(initial: Record<string, AnyNode> = {}) {
const state = {
nodes: { ...initial } as Record<AnyNodeId, AnyNode>,
rootNodeIds: [] as AnyNodeId[],
dirtyNodes: new Set<AnyNodeId>(),
createNode(node: AnyNode) {
state.nodes[node.id] = node
},
updateNode(id: AnyNodeId, data: Partial<AnyNode>) {
const existing = state.nodes[id]
if (existing) state.nodes[id] = { ...existing, ...data } as AnyNode
},
deleteNode(id: AnyNodeId) {
delete state.nodes[id]
},
markDirty(id: AnyNodeId) {
state.dirtyNodes.add(id)
},
}
let paused = 0
const temporal = {
getState: () => ({
pause: () => {
paused += 1
},
resume: () => {
paused -= 1
},
}),
}
const store: SceneStoreLike & { _state: typeof state; _pausedCount: () => number } = {
getState: () => state,
temporal,
_state: state,
_pausedCount: () => paused,
}
return store
}
function makeNode(id: string, extra: Record<string, unknown> = {}): AnyNode {
return { id, type: 'site', parentId: null, visible: true, ...extra } as unknown as AnyNode
}
// Tests use short string IDs ("a", "b") for readability. The store's
// AnyNodeId is a branded template-literal type — cast at the boundary.
const id = (s: string) => s as AnyNodeId
const nodes = (store: ReturnType<typeof makeFakeStore>) =>
store._state.nodes as unknown as Record<string, AnyNode>
describe('SceneApi', () => {
beforeEach(() => {
resetSceneHistoryPauseDepth()
})
test('get reads node from store', () => {
const store = makeFakeStore({ a: makeNode('a') })
const api = createSceneApi(store)
expect(api.get(id('a'))).toEqual(makeNode('a'))
expect(api.get(id('missing'))).toBeUndefined()
})
test('update applies patch via store.updateNode', () => {
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
const api = createSceneApi(store)
api.update(id('a'), { visible: false } as Partial<AnyNode>)
expect(nodes(store)['a']).toMatchObject({ visible: false })
})
test('upsert calls createNode and returns the id', () => {
const store = makeFakeStore()
const api = createSceneApi(store)
const returnedId = api.upsert(makeNode('a'))
expect(returnedId).toBe(id('a'))
expect(nodes(store)['a']).toBeDefined()
})
test('delete removes node from store', () => {
const store = makeFakeStore({ a: makeNode('a') })
const api = createSceneApi(store)
api.delete(id('a'))
expect(nodes(store)['a']).toBeUndefined()
})
test('markDirty forwards to store', () => {
const store = makeFakeStore({ a: makeNode('a') })
const api = createSceneApi(store)
api.markDirty(id('a'))
expect(store._state.dirtyNodes.has(id('a'))).toBe(true)
})
test('pauseHistory and resumeHistory bracket store.temporal pause/resume', () => {
const store = makeFakeStore()
const api = createSceneApi(store)
expect(store._pausedCount()).toBe(0)
api.pauseHistory()
expect(store._pausedCount()).toBe(1)
api.resumeHistory()
expect(store._pausedCount()).toBe(0)
})
test('nested pause/resume use a depth counter (single pause call to temporal)', () => {
const store = makeFakeStore()
const api = createSceneApi(store)
api.pauseHistory()
api.pauseHistory()
expect(store._pausedCount()).toBe(1) // only one actual pause
api.resumeHistory()
expect(store._pausedCount()).toBe(1) // still paused — inner resume
api.resumeHistory()
expect(store._pausedCount()).toBe(0)
})
})
describe('SceneApi snapshot / restore', () => {
beforeEach(() => {
resetSceneHistoryPauseDepth()
})
test('restore returns a touched node to its pre-pause state', () => {
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
const api = createSceneApi(store)
api.pauseHistory()
api.update(id('a'), { visible: false } as Partial<AnyNode>)
expect(nodes(store)['a']).toMatchObject({ visible: false })
api.restore(id('a'))
expect(nodes(store)['a']).toMatchObject({ visible: true })
api.resumeHistory()
})
test('restore on a node never touched is a no-op', () => {
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
const api = createSceneApi(store)
api.pauseHistory()
api.restore(id('a'))
expect(nodes(store)['a']).toMatchObject({ visible: true })
api.resumeHistory()
})
test('restoreAll reverts every touched node', () => {
const store = makeFakeStore({
a: makeNode('a', { visible: true }),
b: makeNode('b', { visible: true }),
})
const api = createSceneApi(store)
api.pauseHistory()
api.update(id('a'), { visible: false } as Partial<AnyNode>)
api.update(id('b'), { visible: false } as Partial<AnyNode>)
api.restoreAll()
expect(nodes(store)['a']).toMatchObject({ visible: true })
expect(nodes(store)['b']).toMatchObject({ visible: true })
api.resumeHistory()
})
test('restore re-creates a node that was deleted mid-pause', () => {
const original = makeNode('a', { visible: true })
const store = makeFakeStore({ a: original })
const api = createSceneApi(store)
api.pauseHistory()
api.delete(id('a'))
expect(nodes(store)['a']).toBeUndefined()
api.restore(id('a'))
expect(nodes(store)['a']).toEqual(original)
api.resumeHistory()
})
test('restore deletes a node that was upserted mid-pause', () => {
const store = makeFakeStore()
const api = createSceneApi(store)
api.pauseHistory()
api.upsert(makeNode('a'))
expect(nodes(store)['a']).toBeDefined()
api.restore(id('a'))
expect(nodes(store)['a']).toBeUndefined()
api.resumeHistory()
})
test('snapshot is dropped on resumeHistory; restore after resume is a no-op', () => {
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
const api = createSceneApi(store)
api.pauseHistory()
api.update(id('a'), { visible: false } as Partial<AnyNode>)
api.resumeHistory()
api.restore(id('a')) // snapshot gone — no effect
expect(nodes(store)['a']).toMatchObject({ visible: false })
})
test('only the first mutation in a pause window captures the original', () => {
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
const api = createSceneApi(store)
api.pauseHistory()
api.update(id('a'), { visible: false } as Partial<AnyNode>)
api.update(id('a'), { visible: true } as Partial<AnyNode>) // second update — must not overwrite snapshot
api.update(id('a'), { visible: false } as Partial<AnyNode>)
api.restore(id('a'))
expect(nodes(store)['a']).toMatchObject({ visible: true }) // the *first* pre-pause value
api.resumeHistory()
})
})
+104
View File
@@ -0,0 +1,104 @@
import type { AnyNode, AnyNodeId } from '../schema/types'
import { pauseSceneHistory, resumeSceneHistory } from '../store/history-control'
import type { SceneApi } from './types'
/**
* Minimal store shape this module depends on.
*
* Decoupled from `useScene` directly so the production singleton and tests can
* share one factory. The full store implements a superset.
*/
export type SceneStoreLike = {
getState: () => {
nodes: Record<AnyNodeId, AnyNode>
rootNodeIds: AnyNodeId[]
dirtyNodes: Set<AnyNodeId>
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
deleteNode: (id: AnyNodeId) => void
markDirty: (id: AnyNodeId) => void
}
temporal: {
getState: () => { pause: () => void; resume: () => void }
}
}
/**
* Creates a {@link SceneApi} backed by a store.
*
* Snapshot semantics:
* - `pauseHistory()` starts a copy-on-write window. The first time `update`,
* `upsert`, or `delete` touches a node id, the pre-change value is captured.
* - `restore(id)` and `restoreAll()` apply the captured value back. Either is
* safe to call only while a pause window is active.
* - `resumeHistory()` drops the snapshot.
*
* Snapshots are lazy and bounded by the number of nodes touched during the
* pause window — never an upfront clone of the entire scene.
*/
export function createSceneApi(store: SceneStoreLike): SceneApi {
let snapshot: Map<AnyNodeId, AnyNode | null> | null = null
function captureIfNeeded(id: AnyNodeId): void {
if (!snapshot || snapshot.has(id)) return
const existing = store.getState().nodes[id]
snapshot.set(id, existing ?? null)
}
return {
get<N extends AnyNode = AnyNode>(id: AnyNodeId): N | undefined {
return store.getState().nodes[id] as N | undefined
},
update(id, patch) {
captureIfNeeded(id)
store.getState().updateNode(id, patch)
},
upsert(node, parentId) {
captureIfNeeded(node.id)
store.getState().createNode(node, parentId)
return node.id
},
delete(id) {
captureIfNeeded(id)
store.getState().deleteNode(id)
},
restore(id) {
if (!snapshot) return
const original = snapshot.get(id)
if (original === undefined) return
const current = store.getState().nodes[id]
if (original === null) {
if (current) store.getState().deleteNode(id)
} else if (!current) {
store.getState().createNode(original)
} else {
store.getState().updateNode(id, original)
}
},
restoreAll() {
if (!snapshot) return
for (const id of snapshot.keys()) {
this.restore(id)
}
},
markDirty(id) {
store.getState().markDirty(id)
},
pauseHistory() {
pauseSceneHistory(store)
if (!snapshot) snapshot = new Map()
},
resumeHistory() {
resumeSceneHistory(store)
snapshot = null
},
}
}
+930
View File
@@ -0,0 +1,930 @@
import type { ComponentType } from 'react'
import type { Object3D } from 'three'
import type { ZodObject, z } from 'zod'
import type { AnyNode, AnyNodeId } from '../schema/types'
// ─── GeometryContext ─────────────────────────────────────────────────
//
// Read-only scene access passed to `def.geometry(node, ctx)`. Most kinds'
// builders ignore `ctx` and read only `node` (shelf, item, spawn). Kinds
// whose meshes reference other nodes by ID — wall miters with siblings,
// door cutouts read parent wall — use `ctx` to resolve those references
// without importing `useScene`. Builders stay pure and unit-testable.
//
// Future extension: `levelData?: { miters?: ... }` for level-scoped batch
// data (wall mitering across an entire level). Decided alongside the wall
// migration off its dedicated system (Phase 3+).
export type GeometryContext = {
/** Look up any node by ID. Returns undefined if the node doesn't exist. */
resolve: <N = AnyNode>(id: AnyNodeId) => N | undefined
/** Resolved children of this node (filters out unresolvable IDs). */
children: AnyNode[]
/** Same kind, same parent — drives wall mitering / endpoint-match. */
siblings: AnyNode[]
/** Resolved parent (null for root-level nodes). */
parent: AnyNode | null
/**
* Pre-computed level-batch data, populated by the dispatcher when the
* kind declares `def.computeLevelData`. Shared across every
* `def.geometry(node, ctx)` call in the same level batch within a
* single frame, so kinds whose geometry depends on cross-sibling
* data (wall mitering, gradient sky uniforms across a zone, etc.)
* don't pay an O(N²) recomputation cost.
*
* Typed as `unknown` at the framework boundary — kinds cast to their
* own `LevelData` shape inside `def.geometry` (the same kind owns
* both the `computeLevelData` return shape and the `geometry`
* consumer, so the cast is internal). Only populated for `def.
* geometry` calls today; not used by `def.floorplan` (which already
* has cheap access to siblings through `ctx.siblings`).
*/
levelData?: unknown
/**
* Optional view state — only populated for `def.floorplan` builders. The
* 2D floor-plan layer surfaces selection / hover here so kinds can vary
* their output (themed stroke when selected, endpoint dots when
* selected, hatch overlay, hover-side highlight). For `def.geometry`
* (3D) this is always undefined — the 3D selection outline is handled
* by the merged-outline post-process pass instead.
*/
viewState?: {
selected: boolean
/** Marquee or programmatic highlight — shows selected chrome without keyboard focus. */
highlighted: boolean
/** Pointer-hovered. */
hovered: boolean
/**
* True while this node is the target of an active 2D move (i.e.
* `useEditor.movingNode === node`). Used by kinds whose move
* preview includes extra chrome — e.g. door / window emit
* dimension lines showing the distance to adjacent openings or
* wall ends only during the move.
*/
moving: boolean
/**
* The kind's theme palette. Theme-aware colors (selection stroke,
* endpoint handle fill, hatch color) live here so kinds don't need
* to import `useViewer.theme` themselves.
*/
palette: FloorplanPalette
}
}
// ─── FloorplanPalette ────────────────────────────────────────────────
//
// Centralised set of themed colors that kinds pull from when building
// their floor-plan geometry. Mirrors the legacy `FloorplanPalette` in
// `floorplan-panel.tsx`. The 2D layer constructs this from
// `useViewer.theme` and passes it via `GeometryContext.viewState.palette`.
export type FloorplanPalette = {
selectedStroke: string
selectedFill: string
/** Hatch / cross-stroke color used for selected fills with patterns. */
selectedHatch: string
/**
* Stroke colour applied to a wall (and fence by analogy) when the
* pointer hovers it. Light blue in the legacy palette — distinct from
* the orange endpoint-handle hover so the body and its handles can
* both glow independently. Pass through `viewState.palette.wall
* HoverStroke` in `def.floorplan` when `viewState.hovered === true`
* and the node isn't selected.
*/
wallHoverStroke: string
endpointHandleFill: string
endpointHandleStroke: string
endpointHandleHoverStroke: string
endpointHandleActiveFill: string
endpointHandleActiveStroke: string
/**
* Curve sagitta handle slot — distinct teal colour-set the legacy
* `FloorplanWallCurveLayer` uses so users can tell endpoint dots
* (orange) and curve dots (teal) apart at a glance.
*/
curveHandleFill: string
curveHandleStroke: string
curveHandleHoverStroke: string
measurementStroke: string
measurementLabelBackground: string
measurementLabelText: string
}
// ─── FloorplanGeometry ───────────────────────────────────────────────
//
// Output shape for `def.floorplan(node, ctx)`. The floor-plan panel
// converts these primitives to React-SVG elements via a generic renderer
// — kinds never touch SVG nodes directly. Coordinates are level-local
// meters; the panel handles world→SVG transform via its viewBox.
//
// Visual styling lives in the geometry so an AI-authored kind can pick
// its own colors without needing to know about CSS / theme tokens. The
// renderer maps these directly to SVG attributes.
export type FloorplanPoint = readonly [x: number, y: number]
export type FloorplanStyle = {
stroke?: string
fill?: string
strokeWidth?: number
strokeDasharray?: string
opacity?: number
/**
* When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth`
* as a constant screen-pixel width regardless of viewport zoom. Maps
* straight to the SVG `vector-effect` attribute. Default (undefined)
* treats `strokeWidth` as plan-unit metres.
*
* Kinds that emit hand-drawn-looking strokes (fence body, wall hairlines,
* post markers) want non-scaling so the visual weight stays stable as
* the user zooms. Kinds whose stroke represents a real-world thickness
* (wall body in floor plan, slab outline) leave it undefined.
*/
vectorEffect?: 'non-scaling-stroke'
strokeLinecap?: 'butt' | 'round' | 'square'
strokeLinejoin?: 'miter' | 'round' | 'bevel'
strokeOpacity?: number
fillOpacity?: number
}
// ─── ToolHint ────────────────────────────────────────────────────────
//
// A single key + label entry in the contextual shortcut hint panel.
// `HelperManager` consults `def.toolHints` when the active tool matches
// a registered kind; matches the existing per-tool helper components
// today (e.g. WallHelper renders three of these entries).
export type ToolHint = {
/** Key combo or input label, e.g. 'Left click', 'Shift', 'Esc'. */
key: string
/** Description of what the input does. Sentence case. */
label: string
}
export type FloorplanGeometry =
| ({ kind: 'path'; d: string } & FloorplanStyle)
| ({ kind: 'polygon'; points: readonly FloorplanPoint[] } & FloorplanStyle)
| ({
kind: 'polyline'
points: readonly FloorplanPoint[]
} & FloorplanStyle)
| ({
kind: 'rect'
x: number
y: number
width: number
height: number
rx?: number
ry?: number
} & FloorplanStyle)
| ({ kind: 'circle'; cx: number; cy: number; r: number } & FloorplanStyle)
| ({
kind: 'line'
x1: number
y1: number
x2: number
y2: number
} & FloorplanStyle)
/**
* Plain SVG text in plan space. Used for short labels that need to
* sit at a specific plan coordinate — e.g. the elevator served-level
* chips' floor numbers. Rotates with the floor plan's transform
* (same as polygon coordinates) so it shares the building's
* orientation. For text that needs to stay screen-upright regardless
* of plan rotation, use `dimension-label` instead (it auto-flips
* upside-down labels).
*
* `fontSize` is in plan metres — typical values are 0.10.2m. The
* registry layer doesn't apply any text-rendering chrome (no plate,
* no rotation auto-flip) — it's just a styled `<text>` element.
*/
| {
kind: 'text'
x: number
y: number
text: string
fontSize: number
fill?: string
fontWeight?: number | string
fontFamily?: string
textAnchor?: 'start' | 'middle' | 'end'
dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'alphabetic'
opacity?: number
/**
* Outlined-text styling — when `stroke` is set the renderer applies
* `stroke` / `strokeWidth` plus `paintOrder='stroke'` so the stroke
* is drawn under the fill. Used by zone name labels for the
* "white text inside a colored outline" look that stays legible
* against any fill color.
*/
stroke?: string
strokeWidth?: number
paintOrder?: 'stroke' | 'fill' | 'normal'
}
/**
* Bitmap overlay — captured top-down asset thumbnail, AI-generated
* floor-plan symbol, scan slice, etc. `url` is passed through the
* editor's `loadAssetUrl` resolver (handles CDN / Supabase storage),
* so kinds emit the raw `asset.floorPlanUrl` and don't worry about
* fetching.
*
* `rotation` is in radians around `center`. The image is drawn at
* `center` with size `width × height` in plan-local metres;
* `preserveAspectRatio` controls letterboxing (default
* `'xMidYMid meet'`).
*/
| {
kind: 'image'
url: string
center: FloorplanPoint
width: number
height: number
rotation?: number
preserveAspectRatio?: string
opacity?: number
}
| {
kind: 'group'
children: FloorplanGeometry[]
/** Optional transform applied to all children. Rotation in radians. */
transform?: { translate?: FloorplanPoint; rotate?: number }
}
/**
* Hatched fill overlay — same polygon shape as the kind's main fill but
* stroked with diagonal lines on top. Used for the selected-wall hatch
* effect from the legacy floor-plan panel. The 2D layer mounts a
* shared `<pattern>` in `<defs>` and references it via `fill=url(...)`.
*/
| { kind: 'hatch'; points: readonly FloorplanPoint[]; color: string; opacity?: number }
/**
* Transparent click-detection segment. Sits on top of the kind's main
* geometry with a wide stroke so the user doesn't need to pixel-hunt
* the polygon. `select` is the only affordance for now — clicking
* triggers selection of the owning node.
*/
| {
kind: 'hit-line'
x1: number
y1: number
x2: number
y2: number
/** Stroke width in screen pixels — converted to plan units by the dispatcher. */
strokeWidthPx: number
cursor?: string
}
/**
* Endpoint manipulation handle — the 5-circle stack from the legacy
* floor-plan: outer hover glow ring + hover ring + filled outer +
* inner dot + transparent hit. Rendered with theme-aware colors from
* `viewState.palette`. `affordance` keys into a kind-owned drag flow
* the dispatcher invokes; `payload` is opaque kind data the
* affordance handler unpacks.
*/
| {
kind: 'endpoint-handle'
point: FloorplanPoint
/** `active` = currently being dragged; `idle` = visible but inert. */
state: 'idle' | 'active'
/**
* Visual colour-set. `'endpoint'` (default) → orange — wall /
* fence endpoints, polygon vertices. `'curve'` → teal — the
* sagitta midpoint handle. Other values are reserved for future
* affordances (rotation, scale) without expanding the union.
*/
variant?: 'endpoint' | 'curve'
affordance: string
payload: unknown
}
/**
* Smaller "insert here" handle drawn between two polygon vertices.
* Visually a small white dot with a `+` icon; hover-expanded. Triggers
* an affordance that typically inserts a new vertex at the midpoint
* and then drags it (matches the legacy slab / ceiling boundary
* editor's edge-midpoint behaviour).
*/
| {
kind: 'midpoint-handle'
point: FloorplanPoint
affordance: string
payload: unknown
}
/**
* Hit-target along an entire polygon edge. Renders as a transparent
* wide stroke for click detection; the dispatcher overlays a glow +
* solid stroke when hovered or actively being dragged. Used by the
* slab / ceiling boundary editor's "drag whole edge perpendicular"
* affordance — both endpoints translate together along the edge
* normal.
*/
| {
kind: 'edge-handle'
x1: number
y1: number
x2: number
y2: number
affordance: string
payload: unknown
}
/**
* "Grab to move" handle drawn at a node's centroid — the orange dot
* users click-and-drag to move a door / window / item in the
* floorplan without going through the inspector's Move button.
*
* Pointer-down on the handle sets `useEditor.movingNode` to the
* owning node, which `FloorplanRegistryMoveOverlay` picks up and
* routes through the kind's `def.floorplanMoveTarget`. So both
* entry points (Move button + dot grab) share the same move
* pipeline — no parallel kind-side logic.
*/
| {
kind: 'move-handle'
point: FloorplanPoint
}
/**
* Centered length / distance label. Renders as a small rounded
* background plate with text, oriented along `angle` (radians). The
* 2D layer flips the label upright when it would otherwise be upside
* down. Use this for simple "what length am I?" badges (fence, item
* width, draft preview).
*/
| {
kind: 'dimension-label'
cx: number
cy: number
text: string
/** Rotation in radians. The renderer auto-flips to keep text upright. */
angle: number
}
/**
* Architect's dimension overlay — extension lines from the edge
* endpoints out past the dimension line, two dimension line halves
* with the label sitting in the gap, end ticks perpendicular to the
* line. Used for the selected wall's full measurement; the rounded
* plate label is the wrong shape when you want plan-drawing chrome.
*
* The renderer computes the segment geometry from these inputs so the
* kind only needs to know "where is the edge and which way does the
* dimension line offset." `offsetNormal` is a unit vector
* perpendicular to the edge; pass the *outward* normal so the line
* sits on the side facing away from the wall interior.
*/
| {
kind: 'dimension'
start: FloorplanPoint
end: FloorplanPoint
/** Outward-pointing unit normal — the dimension line offsets along this. */
offsetNormal: FloorplanPoint
/** Distance (plan units) from the edge to the dimension line. */
offsetDistance: number
/** How far past the offset point the extension line continues. */
extensionOvershoot: number
text: string
/** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string
}
// ─── FloorplanAffordance ─────────────────────────────────────────────
//
// 2D drag session contract for floor-plan interactions. The registry
// layer (`FloorplanRegistryLayer`) drives the SVG event plumbing; each
// affordance handler owns the actual mutation logic for its kind.
//
// Lifecycle:
// 1. Pointer-down on a handle whose `affordance` key matches.
// 2. Layer captures node snapshots for `affectedIds` and pauses
// history.
// 3. Layer calls `apply` on every pointer-move with the current plan
// point + modifier keys.
// 4. On pointer-up: layer reads the resulting scene state, reverts to
// the snapshot (still paused, untracked), resumes history, then
// re-applies the final state as a single tracked change (single-
// undo dance — same shape as Stage D 3D moves).
// 5. On pointer-cancel / unmount: revert + resume without committing.
//
// `apply` is expected to call `scene.updateNodes` directly to drive
// previews — the layer doesn't keep a separate draft state.
export type FloorplanAffordancePoint = readonly [x: number, y: number]
export type FloorplanAffordanceModifiers = {
shiftKey: boolean
altKey: boolean
ctrlKey: boolean
metaKey: boolean
}
export type FloorplanAffordanceSession = {
/** Node IDs the drag may mutate. Used by the dispatcher for the snapshot. */
affectedIds: AnyNodeId[]
/**
* Run a single drag tick. Implementations call `scene.updateNodes` to
* preview the next position. Snap logic, linked-node cascade, and
* angle locking live here.
*/
apply(args: {
planPoint: FloorplanAffordancePoint
modifiers: FloorplanAffordanceModifiers
}): void
/**
* Called on pointer-up. Return `true` if the scene's current state
* should be committed; `false` reverts to the snapshot (e.g. wall too
* short, vertex collapsed onto neighbour).
*/
canCommit(): boolean
}
export type FloorplanAffordance<N> = {
start(args: {
node: N
/** Opaque kind-specific payload from the handle primitive. */
payload: unknown
/** Current scene snapshot at drag start. */
nodes: Record<AnyNodeId, AnyNode>
/** Initial pointer position in plan coordinates. */
initialPlanPoint: FloorplanAffordancePoint
}): FloorplanAffordanceSession
}
// ─── FloorplanMoveTarget ─────────────────────────────────────────────
//
// Kind-specific 2D move-on-floorplan handler. Distinct from
// `FloorplanAffordance` because the lifecycle is different:
//
// - `FloorplanAffordance` is **handle-driven** — the user pointer-downs
// on a specific handle (endpoint dot, vertex, edge), drags, releases.
// Has an `initialPlanPoint`. One drag = one session.
// - `FloorplanMoveTarget` is **movingNode-driven** — the user clicks
// "Move" in the inspector / action menu, the floor-plan tracks the
// cursor from that moment until pointer-up or Esc. No initial
// pointer-down. The session starts when `useEditor.movingNode` is
// set to a node whose kind exposes `floorplanMoveTarget`.
//
// Usage:
//
// - door / window: pointer must hit a wall in plan space; commit
// re-anchors to the new wall (parentId + wallId + local position +
// side + rotation). Reuses `door-math` / `window-math` clamp +
// overlap helpers.
// - item with `attachTo: 'wall'` / `'wall-side'`: same as door /
// window but the local Y is free (item can move up/down the wall).
// - item with `attachTo: 'ceiling'`: hit-test ceiling polygons,
// reparent on transition.
// - item with `attachTo: 'floor'` (or no attachTo): point-in-slab
// check, snap to slab elevation.
//
// Falls back to `FloorplanRegistryMoveOverlay`'s generic free-floating
// translate when `floorplanMoveTarget` is unset on the kind.
export type FloorplanMoveTargetSession = {
/** Node IDs the move may mutate. Used by the dispatcher for snapshot capture. */
affectedIds: AnyNodeId[]
/**
* Single move-preview tick. Implementations call `scene.updateNodes`
* directly to drive the live preview (no separate draft state).
*/
apply(args: {
planPoint: FloorplanAffordancePoint
modifiers: FloorplanAffordanceModifiers
}): void
/**
* Called on pointer-up. Return `true` to commit the current scene
* state; `false` reverts to the snapshot (e.g. dropped in invalid
* area, overlap detected, ...).
*/
canCommit(): boolean
}
export type FloorplanMoveTarget<N> = (args: {
node: N
nodes: Record<AnyNodeId, AnyNode>
}) => FloorplanMoveTargetSession
// ─── Plugin manifest ─────────────────────────────────────────────────
export type Plugin = {
id: string
apiVersion: 1
nodes?: AnyNodeDefinition[]
}
// ─── NodeDefinition ──────────────────────────────────────────────────
export type AnyNodeDefinition = NodeDefinition<ZodObject<any>>
export type NodeDefinition<S extends ZodObject<any>> = {
kind: string
schemaVersion: number
schema: S
category: NodeCategory
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
migrate?: Record<number, (old: unknown) => unknown>
capabilities: Capabilities
relations?: Relations
parametrics?: ParametricDescriptor<z.infer<S>>
/**
* Renderer for this kind. Optional under the three-checkbox composition
* model (see `wiki/architecture/node-definitions.md`): when omitted, the
* framework mounts a generic empty-group renderer that the per-kind
* geometry/system fills. Required today only because the generic
* renderer is not yet implemented — Phase 4 lands it, then this field
* becomes truly optional at runtime too. Making the type optional now so
* milestone-A skeletons (like wall) can compile before their runtime
* port; downstream consumers (`<NodeRenderer>`, `RegisteredSystems`)
* already null-guard on `def.renderer` so omitting it is safe.
*/
renderer?: RendererSource<z.infer<S>>
/**
* Pure geometry builder. When set, the framework's generic
* `<GeometrySystem>` calls this on every dirty mark — `nodes` keyed by
* `def.geometry`'s presence are picked up; the returned `Object3D`'s
* children replace the registered group's children. Together with
* `<ParametricNodeRenderer>` this lets a kind ship without per-kind
* `renderer.tsx` or `system.tsx` files (see
* `wiki/architecture/node-definitions.md`). Combine with `renderer` if
* you want JSX-side composition (drei, `<Html>`, GLB) AND parametric
* rebuilds; combine with `system` if you also need per-frame imperative
* work (animations, named-mesh material poking).
*/
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
/**
* Level-batch precompute hook. Called by `<GeometrySystem>` once per
* level per frame, **before** the per-node `def.geometry` calls in
* that batch. The result lands in `ctx.levelData` for every node in
* the same level.
*
* Used by kinds whose geometry depends on cross-sibling data that
* would be O(N²) to recompute per node:
* - wall: `calculateLevelMiters(walls)` — every wall's mesh
* reads its junctions from the level-wide miter graph.
* - zone (planned): shared TSL gradient uniforms.
*
* `siblings` is every node of this kind in the same level (including
* the dirty ones). The dispatcher de-duplicates per level so this
* runs once even when many walls are dirty in the same frame.
*/
computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown
/**
* Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
* plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
* Object3D. Coordinates are level-local meters — the floor-plan panel
* applies the world→SVG transform.
*
* Returns `null` when the kind shouldn't appear in floor plan (e.g. an
* invisible utility node, or a kind that's 3D-only). Kinds that need
* floor-plan rendering but no 3D mesh set `floorplan` without `geometry`.
*
* See `wiki/architecture/node-definitions.md` ("floor-plan rendering"
* section) and Phase 5 of the registry plan for the migration plan off
* the legacy `floorplan-panel.tsx` monolith.
*/
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
/**
* 2D drag affordances keyed by the string identifier emitted on
* `endpoint-handle` (and similar interactive floor-plan primitives) via
* the `affordance` field. The floor-plan registry layer calls
* `def.floorplanAffordances?.[affordance].start({...})` on pointer-down,
* receives a session, calls `apply(...)` on pointer-move and
* `commit()` / `cancel()` on pointer-up / pointer-cancel. The session
* mutates scene state directly during `apply`; the dispatcher handles
* the snapshot + single-undo dance around it.
*
* Mirrors the existing 3D `affordanceTools` map but for 2D SVG events,
* and operates on plain JS data instead of mounting React. Kinds with
* both 3D and 2D affordances expose both fields — they're independent.
*/
floorplanAffordances?: Record<string, FloorplanAffordance<z.infer<S>>>
/**
* Kind-specific 2D move handler for `useEditor.movingNode`-driven
* placement in the floor plan. When set, `FloorplanRegistryMove
* Overlay` invokes this once when `movingNode` becomes a node of
* this kind, and drives the session through pointer events until
* pointer-up / Esc. Falls back to the generic free-floating
* translate when unset.
*
* Use this for kinds whose move semantics are anchor-aware:
* doors / windows need wall hits + reparenting; items with
* `attachTo` need parent-surface hits. Kinds with simple
* translate-on-XZ semantics (shelf, spawn, fence) leave this
* unset and rely on the generic overlay path.
*/
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
system?: SystemContribution
tool?: LazyComponent
/**
* Stage-D drag-affordance components — one per kind-owned editor mode
* triggered by `useEditor` state. Component receives `{ node }` as its
* sole prop. Lazy-loaded by ToolManager when the corresponding editor
* state activates (e.g. `curvingFence` → `affordanceTools.curve`).
*
* Each component is the thin React wrapper around a pure DragAction
* primitive that lives in the kind's `actions/` folder. The split keeps
* the action data unit-testable while letting the wrapper consume
* `useDragAction` + cursor visuals.
*
* Generic record so per-kind state names don't need to land in the
* core type system. ToolManager looks up by string key.
*/
affordanceTools?: Record<string, () => Promise<{ default: ComponentType<any> }>>
affordances?: Affordance<z.infer<S>>[]
/**
* Contextual shortcut hints shown by `HelperManager` when this kind's
* tool is active. Pure data — `HelperManager` renders these via a
* generic <RegisteredToolHelper>. Drops the need for a hand-written
* `<XxxHelper>` component per kind.
*
* Static array for now (covers ~all current uses). If a kind needs
* state-dependent hints (e.g. different keys during a drag), it keeps
* its bespoke helper component instead.
*/
toolHints?: ToolHint[]
/**
* Optional translucent preview of the node — used by the move tool to
* show where the node will land, and by the placement tool's cursor.
* Receives the partially-resolved node (or a default-shaped stub during
* placement before any commit has happened). Phase 4 may merge this with
* the renderer behind an `opacity` prop.
*/
preview?: () => Promise<{ default: ComponentType<{ node: z.infer<S> }> }>
presentation?: Presentation
mcp?: McpOverrides
}
export type NodeCategory = 'site' | 'structure' | 'furnish' | 'analysis' | 'utility'
// ─── Presentation (tool palette + UI surface) ────────────────────────
/**
* UI metadata for surfacing a node kind in the tool palette and elsewhere.
* Phase 4 ships the consumer (auto-derived palette buttons); definitions can
* declare this from Phase 2 onward so the spike's `column` and `shelf` show up
* correctly the moment the palette consumes the registry.
*/
export type Presentation = {
/** Sentence-case label shown in palette buttons, breadcrumbs, etc. */
label: string
/** Optional longer tooltip / help text. */
description?: string
/** Icon for palette buttons and tree views. */
icon: IconRef
/** Tool palette section. Defaults to `category` when omitted. */
paletteSection?: 'site' | 'structure' | 'furnish'
/** Sort key within a palette section; lower numbers come first. */
paletteOrder?: number
/** Set true for kinds that exist but should NOT appear in the palette
* (containers like `site`/`building`/`level`, internal nodes). */
hidden?: boolean
}
export type IconRef =
/** Iconify identifier, e.g. `lucide:square`. Matches the @iconify-react
* setup the editor app already uses for tool icons. */
| { kind: 'iconify'; name: string }
/** URL path to a raster or vector asset (PNG/SVG/...). Matches the
* palette's PNG/SVG assets — use this to share the same artwork
* between the bottom toolbar and the inspector title. */
| { kind: 'url'; src: string }
/** Inline SVG path data. Use for asset packs or plugins that want a custom
* mark without contributing a React component. */
| { kind: 'svg'; viewBox: string; path: string }
/** Custom React component, lazy-loaded. Use sparingly — adds a Suspense
* boundary per icon. */
| { kind: 'component'; module: () => Promise<{ default: ComponentType }> }
export type LazyComponent = () => Promise<{ default: ComponentType }>
export type RendererSource<N> =
| {
kind: 'parametric'
module: () => Promise<{ default: ComponentType<{ node: N }> }>
}
| { kind: 'glb'; getAsset: (n: N) => AssetRef }
| { kind: 'instanced-glb'; getAsset: (n: N) => AssetRef }
export type AssetRef = {
id: string
src: string
}
export type SystemContribution = {
module: () => Promise<{ default: ComponentType }>
priority?: number
}
export type McpOverrides = {
description?: string
semantic?: boolean
}
// ─── Capabilities ────────────────────────────────────────────────────
export type Capabilities = {
movable?: MovableConfig
rotatable?: RotatableConfig
scalable?: ScalableConfig
hostable?: HostableConfig
cuttable?: CuttableConfig
snappable?: SnappableConfig
surfaces?: SurfacesConfig
duplicable?: boolean
deletable?: boolean
groupable?: boolean
selectable?: SelectableConfig
interactive?: boolean
floorPlaced?: FloorPlacedConfig
}
export type CapabilityCtx = { node: AnyNode }
export type MovableConfig = {
axes: ReadonlyArray<'x' | 'y' | 'z'>
gridSnap?: boolean
override?: (ctx: CapabilityCtx) => MovableConfig | null
}
export type RotatableConfig = {
axes: ReadonlyArray<'x' | 'y' | 'z'>
snapAngles?: readonly number[]
override?: (ctx: CapabilityCtx) => RotatableConfig | null
}
export type ScalableConfig = {
axes: ReadonlyArray<'x' | 'y' | 'z'>
min?: number
max?: number
override?: (ctx: CapabilityCtx) => ScalableConfig | null
}
export type HostableConfig = {
parents: readonly string[]
align?: 'top' | 'bottom' | 'center' | 'face'
fromAsset?: 'attachTo'
modes?: Record<string, Partial<HostableConfig>>
override?: (ctx: CapabilityCtx) => HostableConfig | null
}
export type CuttableConfig = {
hostKinds: readonly string[]
override?: (ctx: CapabilityCtx) => CuttableConfig | null
}
export type SnappableConfig = {
points?: readonly SnapPointKind[]
override?: (ctx: CapabilityCtx) => SnappableConfig | null
}
export type SnapPointKind = 'start' | 'end' | 'midpoint' | 'center' | 'corners'
export type SurfacesConfig = {
top?: { height: number | ((n: AnyNode) => number) }
sides?: { faces: 'all' | ReadonlyArray<readonly [number, number, number]> }
custom?: SurfaceQuery
}
export type SurfaceQuery = (n: AnyNode) => SurfacePoint[]
export type SurfacePoint = {
position: readonly [number, number, number]
normal: readonly [number, number, number]
}
export type SelectableConfig = {
hitVolume?: 'bbox' | 'mesh' | 'none'
override?: (ctx: CapabilityCtx) => SelectableConfig | null
}
/**
* Floor-placed kinds rest directly on a level and need their Y lifted by
* any slab the footprint overlaps. The generic `<FloorElevationSystem>`
* computes `slabElevation + node.position[1]` and writes it onto the
* registered mesh on every dirty mark. `footprint` returns the world-space
* footprint the spatial-grid manager uses to find overlapping slabs;
* `applies` is an optional predicate to skip nodes that share a kind but
* are mounted off-floor (items attached to a wall / ceiling).
*/
export type FloorPlacedConfig = {
footprint: (node: AnyNode) => {
dimensions: [number, number, number]
rotation: [number, number, number]
}
applies?: (node: AnyNode) => boolean
}
// ─── Relations ───────────────────────────────────────────────────────
export type Relations = {
linkedBy?: 'endpoint-match' | 'polygon-share' | { custom: (n: AnyNode) => AnyNodeId[] }
hosts?: readonly string[]
affectsSpatial?: readonly string[]
cascadeDelete?: 'descendants' | 'children' | 'none'
}
// ─── ParametricDescriptor ────────────────────────────────────────────
export type ParametricDescriptor<N> = {
groups: ParamGroup<N>[]
invariants?: ReadonlyArray<(n: N) => Issue[]>
derive?: (n: N) => Partial<N>
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
}
export type ParamGroup<N> = {
label: string
fields: ParamField<N>[]
}
export type ParamField<N> =
| {
key: keyof N
kind: 'number'
unit?: string
min?: number
max?: number
step?: number
visibleIf?: (n: N) => boolean
customEditor?: ComponentType
}
| { key: keyof N; kind: 'boolean'; visibleIf?: (n: N) => boolean }
| {
key: keyof N
kind: 'enum'
options: readonly string[]
/** Defaults to 'select' (dropdown). 'segmented' renders the inline
* tabbed switcher — better for short option lists (2-4 items). */
display?: 'select' | 'segmented'
visibleIf?: (n: N) => boolean
}
| { key: keyof N; kind: 'vec3'; visibleIf?: (n: N) => boolean }
| { key: keyof N; kind: 'color'; visibleIf?: (n: N) => boolean }
| { key: keyof N; kind: 'material'; visibleIf?: (n: N) => boolean }
| { key: keyof N; kind: 'ref'; refKind: string; visibleIf?: (n: N) => boolean }
/** Escape hatch for fields that don't map to a single node key —
* derived values (`length` from `start`/`end`), sliders with
* dynamic min/max (curve sagitta bounded by chord length),
* composed editors, etc. The kind owns the rendering and the
* update logic. `key` here is just a stable React key/label. */
| {
key: string
kind: 'custom'
component: ComponentType<{ node: N; onUpdate: (patch: Partial<N>) => void }>
visibleIf?: (n: N) => boolean
}
export type Issue = { field?: string; msg: string; severity?: 'error' | 'warning' }
// ─── Affordance ──────────────────────────────────────────────────────
export type Affordance<N> = {
id: string
mount: 'on-selection' | 'on-hover' | 'always'
enabled?: (n: N, ctx: EditorCtx) => boolean
component: () => Promise<{ default: ComponentType<{ node: N }> }>
}
export type EditorCtx = {
modifiers: Modifiers
}
// ─── DragAction primitive ────────────────────────────────────────────
export type Vec2 = readonly [number, number]
export type Modifiers = { shift: boolean; alt: boolean; ctrl: boolean; meta: boolean }
export type DragAction<Ctx, Draft> = {
begin: (input: { node?: AnyNode; point: Vec2; handleId?: string; modifiers?: Modifiers }) => Ctx
preview: (ctx: Ctx, point: Vec2, modifiers: Modifiers) => Draft
snap?: (draft: Draft, ctx: Ctx, services: SnapServicesLike) => Draft
apply: (draft: Draft, ctx: Ctx, scene: SceneApi) => Iterable<AnyNodeId>
commit?: (draft: Draft, ctx: Ctx, scene: SceneApi) => boolean
cancel: (ctx: Ctx, scene: SceneApi) => void
}
// Phase 1 fleshes out SnapServices; PR 0.1 only needs the placeholder type.
export type SnapServicesLike = unknown
// ─── SceneApi ────────────────────────────────────────────────────────
export type SceneApi = {
get: <N extends AnyNode = AnyNode>(id: AnyNodeId) => N | undefined
update: (id: AnyNodeId, patch: Partial<AnyNode>) => void
upsert: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId
delete: (id: AnyNodeId) => void
restore: (id: AnyNodeId) => void
restoreAll: () => void
markDirty: (id: AnyNodeId) => void
pauseHistory: () => void
resumeHistory: () => void
}
// ─── Registry surface ────────────────────────────────────────────────
export interface NodeRegistry {
has: (kind: string) => boolean
get: (kind: string) => AnyNodeDefinition | undefined
entries: () => IterableIterator<[string, AnyNodeDefinition]>
schemas: () => ZodObject<any>[]
readonly size: number
}
+1
View File
@@ -74,6 +74,7 @@ export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export { RoofSegmentNode, RoofType } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan'
// Nodes
export { ShelfNode } from './nodes/shelf'
export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab'
export { SpawnNode } from './nodes/spawn'
+1
View File
@@ -51,6 +51,7 @@ export const MaterialTarget = z.enum([
'ceiling',
'door',
'window',
'shelf',
])
export type MaterialTarget = z.infer<typeof MaterialTarget>
+91
View File
@@ -0,0 +1,91 @@
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { ItemNode } from './item'
/**
* Parametric shelf — a configurable furniture unit with one or more
* horizontal boards that host other items.
*
* Four styles share the same dimensional schema:
*
* - `wall-shelf` — open boards held by end brackets. `rows > 1` stacks
* evenly-spaced boards. Brackets style: `minimal | industrial | hidden`.
* The v1 archetype.
* - `bookshelf` — full-height cabinet: side panels + multiple shelf
* boards. `columns > 1` adds vertical dividers between sections.
* `withBack` toggles a back panel. `withSides` toggles the side
* panels (`false` = open silhouette held by cross-brace posts).
* - `open-rack` — industrial wire-rack style: four corner posts, no
* side panels, slim boards. `withBack` adds an X-brace.
* - `cubby` — grid of pigeonhole cubicles: `rows × columns` cells
* formed by full back + sides + inner dividers. Each cubicle hosts
* items on its own bottom surface.
*
* `height` is the distance from floor to the underside of the topmost
* board (legacy v1 semantic, preserved so v1 scenes load with identical
* top-board placement). For `rows > 1`, boards are evenly spaced from
* `height / rows` up to `height`. For `cubby`, the height divides into
* `rows` equal-height cubicles.
*
* Items host on each row's top surface via `capabilities.surfaces.custom`.
*/
export const ShelfNode = BaseNode.extend({
id: objectId('shelf'),
type: nodeType('shelf'),
// Hosted items live here — without this field `createNode(item, shelf)`
// would write `item.parentId = shelf.id` but skip the children-list
// update, so the shelf renderer wouldn't pick the item up and React
// would never mount it (the item would exist in `useScene.nodes` but
// not be rendered, making the commit look like "the item went
// somewhere else"). The action's parent-update branch needs the field
// present at parse-time so the children array is always defined.
children: z.array(ItemNode.shape.id).default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Dimensions (meters). Schema-level defaults intentionally reproduce
// the v1 wall-shelf so existing v1 scenes that omit the v2-introduced
// fields (style / rows / columns / with*) load with their original
// visual unchanged. The user-facing "place a fresh shelf" defaults
// (cubby 3x2 @ 1m × 0.5m × 1.8m) live on `shelfDefinition.defaults()`
// and are applied by the placement tool, NOT here.
width: z.number().min(0.3).max(3.0).default(1.2),
depth: z.number().min(0.1).max(1.0).default(0.3),
/** Board thickness — shared by top boards, sides, back, dividers. */
thickness: z.number().min(0.01).max(0.1).default(0.04),
/**
* Distance from floor to the underside of the topmost board. For
* `rows > 1`, intermediate boards are evenly spaced from `height/rows`
* up to `height`.
*/
height: z.number().min(0.05).max(2.5).default(0.9),
// Style + topology — v2 additions, default to v1 visual (single-board
// wall shelf) so v1 scenes are forward-compatible without migration.
style: z.enum(['wall-shelf', 'bookshelf', 'open-rack', 'cubby']).default('wall-shelf'),
rows: z.number().int().min(1).max(8).default(1),
columns: z.number().int().min(1).max(6).default(1),
withBack: z.boolean().default(false),
withSides: z.boolean().default(true),
/**
* Renders a horizontal board at floor level — closes the bottom row of
* a cubby (or the base of a bookshelf) so items can host on a real
* surface rather than the open floor. No-op for `wall-shelf` /
* `open-rack` where the structure has no enclosed bottom cell.
*/
withBottom: z.boolean().default(false),
bracketStyle: z.enum(['minimal', 'industrial', 'hidden']).default('minimal'),
// Paintable surface — same shape walls / slabs / stairs use. The default
// is unset (renders as the off-white `DEFAULT_SHELF_MATERIAL`); paint
// mode writes the chosen catalog material here. Keeping the same field
// names (`material` / `materialPreset`) lets the existing
// `buildSurfaceMaterialPatch` helpers in `material-paint.ts` work
// unchanged once `'shelf'` is added to `MaterialTarget`.
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
})
export type ShelfNode = z.infer<typeof ShelfNode>
+2
View File
@@ -11,6 +11,7 @@ import { LevelNode } from './nodes/level'
import { RoofNode } from './nodes/roof'
import { RoofSegmentNode } from './nodes/roof-segment'
import { ScanNode } from './nodes/scan'
import { ShelfNode } from './nodes/shelf'
import { SiteNode } from './nodes/site'
import { SlabNode } from './nodes/slab'
import { SpawnNode } from './nodes/spawn'
@@ -34,6 +35,7 @@ export const AnyNode = z.discriminatedUnion('type', [
CeilingNode,
RoofNode,
RoofSegmentNode,
ShelfNode,
StairNode,
StairSegmentNode,
ScanNode,
@@ -0,0 +1,244 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../registry/registry'
import type { AnyNodeDefinition, DragAction, Relations, SceneApi } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { createDragSession } from './drag-session'
const id = (s: string) => s as AnyNodeId
function makeSpyScene(initial: Record<string, AnyNode> = {}): SceneApi & {
_calls: {
pauseHistory: number
resumeHistory: number
restoreAll: number
markedDirty: AnyNodeId[]
updated: Array<[AnyNodeId, Partial<AnyNode>]>
}
} {
const calls = {
pauseHistory: 0,
resumeHistory: 0,
restoreAll: 0,
markedDirty: [] as AnyNodeId[],
updated: [] as Array<[AnyNodeId, Partial<AnyNode>]>,
}
const nodes = { ...initial }
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
update: (nid, patch) => {
calls.updated.push([nid, patch])
const existing = nodes[nid as string]
if (existing) nodes[nid as string] = { ...existing, ...patch } as AnyNode
},
upsert: (n: AnyNode) => {
nodes[n.id as string] = n
return n.id
},
delete: (nid) => {
delete nodes[nid as string]
},
restore: () => {},
restoreAll: () => {
calls.restoreAll += 1
},
markDirty: (nid) => {
calls.markedDirty.push(nid)
},
pauseHistory: () => {
calls.pauseHistory += 1
},
resumeHistory: () => {
calls.resumeHistory += 1
},
_calls: calls,
}
}
function makeDef(kind: string, relations?: Relations): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities: {},
relations,
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
}
}
function makeAction(): DragAction<{ id: AnyNodeId }, { x: number }> {
return {
begin: ({ node }) => ({ id: node?.id ?? id('default') }),
preview: (_ctx, point) => ({ x: point[0] }),
apply: (_draft, ctx) => [ctx.id],
cancel: () => {},
}
}
describe('createDragSession', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('start pauses history; commit resumes', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
expect(scene._calls.pauseHistory).toBe(1)
expect(scene._calls.resumeHistory).toBe(0)
session.commit()
expect(scene._calls.resumeHistory).toBe(1)
})
test('cancel resumes history and calls restoreAll', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
session.cancel()
expect(scene._calls.resumeHistory).toBe(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('move runs preview + apply and marks the returned id dirty', () => {
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
expect(session.getDraft()).toEqual({ x: 1 })
expect(scene._calls.markedDirty).toContain(id('a'))
})
test('snap callback is invoked when defined', () => {
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
...makeAction(),
snap: (draft) => ({ x: Math.round(draft.x) }),
}
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(action, scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([0.7, 0], { shift: false, alt: false, ctrl: false, meta: false })
expect(session.getDraft()).toEqual({ x: 1 })
})
test('commit returns false when action.commit returns false; calls action.cancel and restoreAll', () => {
const cancelSpy = mock(() => {})
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
...makeAction(),
cancel: cancelSpy,
commit: () => false,
}
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(action, scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
const result = session.commit()
expect(result).toBe(false)
expect(cancelSpy).toHaveBeenCalledTimes(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('move is a no-op when session is not active', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
expect(scene._calls.markedDirty.length).toBe(0)
})
test('repeated start is a no-op (re-entry guard)', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
session.start({ point: [99, 99] })
expect(scene._calls.pauseHistory).toBe(1) // only one pause
})
test('dispose mid-drag cancels and cleans up', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
expect(session.isActive()).toBe(true)
session.dispose()
expect(session.isActive()).toBe(false)
expect(scene._calls.resumeHistory).toBe(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('dispose when inactive is a no-op', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.dispose()
expect(scene._calls.pauseHistory).toBe(0)
expect(scene._calls.resumeHistory).toBe(0)
})
test('onCommit callback fires on successful commit', () => {
const onCommit = mock(() => {})
const onCancel = mock(() => {})
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
session.start({ point: [0, 0] })
session.commit()
expect(onCommit).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(0)
})
test('onCancel callback fires on explicit cancel', () => {
const onCommit = mock(() => {})
const onCancel = mock(() => {})
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
session.start({ point: [0, 0] })
session.cancel()
expect(onCommit).toHaveBeenCalledTimes(0)
expect(onCancel).toHaveBeenCalledTimes(1)
})
test('dispose does NOT fire onCancel (silent cleanup)', () => {
const onCommit = mock(() => {})
const onCancel = mock(() => {})
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
session.start({ point: [0, 0] })
session.dispose()
expect(onCommit).toHaveBeenCalledTimes(0)
expect(onCancel).toHaveBeenCalledTimes(0)
expect(scene._calls.resumeHistory).toBe(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('dirty cascade fires once per id even across multiple move ticks', () => {
// Register a kind with no relations — cascade returns just {startId}.
registerNode(makeDef('thing'))
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
session.move([2, 0], { shift: false, alt: false, ctrl: false, meta: false })
session.move([3, 0], { shift: false, alt: false, ctrl: false, meta: false })
// a is marked once, not three times
expect(scene._calls.markedDirty.filter((mid) => mid === id('a')).length).toBe(1)
})
test('dirty cascade follows hosts relations from the registry', () => {
registerNode(makeDef('wall', { hosts: ['door'] }))
registerNode(makeDef('door'))
const scene = makeSpyScene({
w: { id: id('w'), type: 'wall', children: [id('d')] } as any,
d: { id: id('d'), type: 'door', parentId: id('w') } as any,
})
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
begin: () => ({ id: id('w') }),
preview: (_ctx, point) => ({ x: point[0] }),
apply: (_draft, ctx) => [ctx.id],
cancel: () => {},
}
const session = createDragSession(action, scene)
session.start({ point: [0, 0], node: { id: id('w') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
// both wall and door marked dirty
expect(scene._calls.markedDirty).toContain(id('w'))
expect(scene._calls.markedDirty).toContain(id('d'))
})
})
+154
View File
@@ -0,0 +1,154 @@
import { type ChildQuery, cascadeDirty, type SpatialQuery } from '../registry/relations-resolver'
import type { DragAction, Modifiers, SceneApi } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
import type { Vec2 } from './snap'
/**
* Pure orchestrator for a single `DragAction` lifecycle:
* begin → (preview → snap? → apply → cascade dirty)* → commit | cancel
*
* Bracketed by `pauseHistory()` / `resumeHistory()` so the entire drag is one
* undo step. The React hook (`useDragAction` in `@pascal-app/editor`) wraps
* this with event subscriptions; tests drive it directly.
*/
export type DragSessionInput = {
node?: AnyNode
point: Vec2
handleId?: string
modifiers?: Modifiers
}
export type DragSessionOptions = {
spatialQuery?: SpatialQuery
childQuery?: ChildQuery
/** Called once the session terminates via `commit()`. */
onCommit?: () => void
/** Called once the session terminates via `cancel()` or `dispose()`. */
onCancel?: () => void
}
export type DragSession<Ctx, Draft> = {
/** Begin the drag — pause history, capture ctx via `action.begin`. */
start: (input: DragSessionInput) => void
/** Per-pointer-move tick — run preview/snap/apply and cascade dirty marks. */
move: (point: Vec2, modifiers: Modifiers) => void
/** Pointer-up / discrete commit. Returns true if `action.commit` agreed. */
commit: () => boolean
/** Pointer-cancel / Esc / external abort — restores all touched nodes. */
cancel: () => void
/** Returns the latest draft `apply` produced (or null before first move). */
getDraft: () => Draft | null
isActive: () => boolean
/** Idempotent cleanup. If active, restores scene state and resumes
* history, but does **not** fire `onCancel`. Use for React-effect
* teardown — onCancel would re-trigger the parent's state machine and
* break StrictMode's double-mount cycle. Esc / external aborts must
* still call `cancel()` directly. */
dispose: () => void
}
const EMPTY_MODIFIERS: Modifiers = { shift: false, alt: false, ctrl: false, meta: false }
export function createDragSession<Ctx, Draft>(
action: DragAction<Ctx, Draft>,
scene: SceneApi,
options: DragSessionOptions = {},
): DragSession<Ctx, Draft> {
let active = false
let ctx: Ctx | null = null
let draft: Draft | null = null
let dirtyMarked = new Set<AnyNodeId>()
function markWithCascade(id: AnyNodeId): void {
if (dirtyMarked.has(id)) return
const ids = cascadeDirty(id, {
scene,
spatialQuery: options.spatialQuery,
childQuery: options.childQuery,
})
for (const dirtyId of ids) {
if (!dirtyMarked.has(dirtyId)) {
scene.markDirty(dirtyId)
dirtyMarked.add(dirtyId)
}
}
}
function terminate(committed: boolean): void {
if (!active) return
active = false
ctx = null
draft = null
dirtyMarked = new Set()
scene.resumeHistory()
if (committed) options.onCommit?.()
else options.onCancel?.()
}
return {
start(input) {
if (active) return // ignore re-entry
scene.pauseHistory()
ctx = action.begin({
node: input.node,
point: input.point,
handleId: input.handleId,
modifiers: input.modifiers ?? EMPTY_MODIFIERS,
})
active = true
},
move(point, modifiers) {
if (!active || ctx == null) return
let next = action.preview(ctx, point, modifiers)
if (action.snap) {
next = action.snap(next, ctx, undefined)
}
draft = next
const dirtyIds = action.apply(next, ctx, scene)
for (const id of dirtyIds) markWithCascade(id)
},
commit() {
if (!active || ctx == null) return false
const ok = action.commit?.(draft as Draft, ctx, scene) ?? true
if (!ok) {
action.cancel(ctx, scene)
scene.restoreAll()
}
terminate(ok)
return ok
},
cancel() {
if (!active || ctx == null) return
action.cancel(ctx, scene)
scene.restoreAll()
terminate(false)
},
getDraft() {
return draft
},
isActive() {
return active
},
dispose() {
if (active && ctx != null) {
action.cancel(ctx, scene)
scene.restoreAll()
// Silent terminate: no onCancel. The caller (e.g. useDragAction's
// effect cleanup) is reacting to the parent unmounting and would
// loop the state machine if onCancel re-set the parent's state.
active = false
ctx = null
draft = null
dirtyMarked = new Set()
scene.resumeHistory()
}
},
}
}
+233
View File
@@ -0,0 +1,233 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../registry/registry'
import type { AnyNodeDefinition, Capabilities, SceneApi } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
import {
canAttach,
clampYToHostTop,
getSurface,
getTopSurfaceHeight,
MAX_HOST_DEPTH,
pickHost,
} from './hosting'
const id = (s: string) => s as AnyNodeId
function makeDef(
kind: string,
capabilities: Capabilities = {},
overrides: Partial<AnyNodeDefinition> = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities,
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
...overrides,
}
}
function makeNode(kind: string, idStr: string, parentId: string | null = null): AnyNode {
return {
id: id(idStr),
type: kind,
parentId: parentId ? id(parentId) : null,
visible: true,
} as unknown as AnyNode
}
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
update: () => {},
upsert: () => id(''),
delete: () => {},
restore: () => {},
restoreAll: () => {},
markDirty: () => {},
pauseHistory: () => {},
resumeHistory: () => {},
}
}
describe('canAttach', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('rejects self-host', () => {
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
const result = canAttach(id('a'), id('a'), scene)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe('self-host')
})
test('rejects missing host', () => {
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
const result = canAttach(id('a'), id('missing'), scene)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe('host-missing')
})
test('detects cycle when host is a descendant of child', () => {
// child=a, host=b, b's parent chain leads back to a → cycle.
const scene = makeFakeScene({
a: makeNode('thing', 'a'),
b: makeNode('thing', 'b', 'c'),
c: makeNode('thing', 'c', 'a'), // c.parent = a, b.parent = c → cycle if a → b
})
const result = canAttach(id('a'), id('b'), scene)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe('cycle')
})
test('rejects when chain would exceed MAX_HOST_DEPTH', () => {
const nodes: Record<string, AnyNode> = {}
for (let i = 0; i <= MAX_HOST_DEPTH; i++) {
nodes[`n${i}`] = makeNode('thing', `n${i}`, i === 0 ? null : `n${i - 1}`)
}
// n6 is already MAX_HOST_DEPTH deep — attaching n_new beneath it would
// push the child to MAX_HOST_DEPTH + 1.
nodes.candidate = makeNode('thing', 'candidate')
const scene = makeFakeScene(nodes)
const result = canAttach(id('candidate'), id(`n${MAX_HOST_DEPTH}`), scene)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe('depth-exceeded')
})
test('accepts attach when chain stays within MAX_HOST_DEPTH', () => {
const scene = makeFakeScene({
root: makeNode('thing', 'root'),
candidate: makeNode('thing', 'candidate'),
})
expect(canAttach(id('candidate'), id('root'), scene).ok).toBe(true)
})
test('rejects host kind not in child def.hostable.parents', () => {
registerNode(makeDef('shelf', { hostable: { parents: ['wall', 'slab'] } }))
const scene = makeFakeScene({
s: makeNode('shelf', 's'),
ceiling: makeNode('ceiling', 'ceiling'),
})
const result = canAttach(id('s'), id('ceiling'), scene)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe('kind-not-allowed')
})
test('accepts when host kind is in parents', () => {
registerNode(makeDef('shelf', { hostable: { parents: ['wall', 'slab'] } }))
const scene = makeFakeScene({
s: makeNode('shelf', 's'),
w: makeNode('wall', 'w'),
})
expect(canAttach(id('s'), id('w'), scene).ok).toBe(true)
})
test('no def or no hostable.parents = no kind restriction', () => {
// Some kinds (e.g. items via catalog) defer to runtime checks instead of
// declaring parents up front. canAttach should not block them.
const scene = makeFakeScene({
i: makeNode('item', 'i'),
w: makeNode('wall', 'w'),
})
expect(canAttach(id('i'), id('w'), scene).ok).toBe(true)
})
test('allows missing child (placement preview before commit)', () => {
const scene = makeFakeScene({ w: makeNode('wall', 'w') })
expect(canAttach(id('future-child'), id('w'), scene).ok).toBe(true)
})
})
describe('getSurface / getTopSurfaceHeight', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('returns null when host has no registered def', () => {
const node = makeNode('mystery', 'm')
expect(getSurface(node)).toBeNull()
expect(getTopSurfaceHeight(node)).toBeNull()
})
test('returns surface config when declared', () => {
registerNode(
makeDef('table', {
surfaces: { top: { height: 0.74 } },
}),
)
const t = makeNode('table', 't')
expect(getSurface(t)?.top?.height).toBe(0.74)
expect(getTopSurfaceHeight(t)).toBe(0.74)
})
test('evaluates function-valued height with the node', () => {
registerNode(
makeDef('shelf', {
surfaces: {
top: {
height: (n: any) => (n.id === id('high') ? 1.8 : 0.3),
},
},
}),
)
expect(getTopSurfaceHeight(makeNode('shelf', 'high'))).toBe(1.8)
expect(getTopSurfaceHeight(makeNode('shelf', 'low'))).toBe(0.3)
})
})
describe('clampYToHostTop', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('clamps to top when host has one', () => {
registerNode(makeDef('table', { surfaces: { top: { height: 0.74 } } }))
expect(clampYToHostTop(makeNode('table', 't'), 5)).toBe(0.74)
})
test('passes through when host has no top surface', () => {
registerNode(makeDef('plain'))
expect(clampYToHostTop(makeNode('plain', 'p'), 5)).toBe(5)
})
})
describe('pickHost', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('returns first candidate that has hostable capability', () => {
registerNode(makeDef('slab', { hostable: { parents: ['*'] } }))
registerNode(makeDef('item')) // no hostable
const candidates = [makeNode('item', 'i'), makeNode('slab', 's')]
const picked = pickHost({
point: [0, 0, 0],
candidates,
placedKind: 'chair',
})
expect(picked?.id).toBe(id('s'))
})
test('returns null when nothing in candidates is hostable', () => {
registerNode(makeDef('item'))
const candidates = [makeNode('item', 'i')]
expect(pickHost({ point: [0, 0, 0], candidates, placedKind: 'chair' })).toBeNull()
})
test('hitTest can reject hostable candidates', () => {
registerNode(makeDef('slab', { hostable: { parents: ['*'] } }))
const candidates = [makeNode('slab', 's1'), makeNode('slab', 's2')]
const picked = pickHost({
point: [0, 0, 0],
candidates,
placedKind: 'chair',
hitTest: (host) => host.id === id('s2'),
})
expect(picked?.id).toBe(id('s2'))
})
})
+149
View File
@@ -0,0 +1,149 @@
import { nodeRegistry } from '../registry/registry'
import type { SceneApi, SurfacesConfig } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
/**
* Maximum depth a node tree can host. Guards items-on-items-on-items chains
* from growing pathological — pre-Phase-1 the editor had no cap. Set high
* enough to allow legitimate stacking (chair on platform on truck on floor)
* while preventing AI/plugin-generated runaway.
*/
export const MAX_HOST_DEPTH = 6
export type Vec3 = readonly [number, number, number]
export type AttachError =
| { kind: 'self-host'; nodeId: AnyNodeId }
| { kind: 'cycle'; nodeId: AnyNodeId; hostId: AnyNodeId }
| { kind: 'depth-exceeded'; depth: number; max: number }
| { kind: 'host-missing'; hostId: AnyNodeId }
| { kind: 'kind-not-allowed'; hostKind: string; allowed: readonly string[] }
export type AttachResult = { ok: true } | { ok: false; error: AttachError }
/**
* Validates that attaching `child` to `host` is safe and either returns an
* actionable error or signals OK. Does NOT mutate the scene — callers apply
* the patch after a successful check.
*
* Rules:
* - A node cannot host itself.
* - The hosting chain (child → host → host.parent → ...) must not contain
* `child` (cycle prevention).
* - The resulting chain must not exceed {@link MAX_HOST_DEPTH}.
* - If the child's NodeDefinition declares `capabilities.hostable.parents`,
* `host.type` must appear in that list.
*/
export function canAttach(childId: AnyNodeId, hostId: AnyNodeId, scene: SceneApi): AttachResult {
if (childId === hostId) {
return { ok: false, error: { kind: 'self-host', nodeId: childId } }
}
const host = scene.get(hostId)
if (!host) {
return { ok: false, error: { kind: 'host-missing', hostId } }
}
const child = scene.get(childId)
if (!child) {
// No child node yet — likely a placement preview. Allow attach to proceed;
// the caller is responsible for ensuring child exists before commit.
return checkDepth(hostId, scene)
}
const childDef = nodeRegistry.get(child.type)
const allowed = childDef?.capabilities.hostable?.parents
if (allowed && allowed.length > 0 && !(allowed as readonly string[]).includes(host.type)) {
return {
ok: false,
error: { kind: 'kind-not-allowed', hostKind: host.type, allowed },
}
}
// Cycle: walk host's ancestors and reject if we hit the child.
let cursor: AnyNode | undefined = host
while (cursor) {
if (cursor.id === childId) {
return { ok: false, error: { kind: 'cycle', nodeId: childId, hostId } }
}
cursor = cursor.parentId ? scene.get(cursor.parentId as AnyNodeId) : undefined
}
return checkDepth(hostId, scene)
}
function checkDepth(hostId: AnyNodeId, scene: SceneApi): AttachResult {
// Count host's own depth (root = 0); attaching adds 1 to the child's depth.
let depth = 0
let cursor: AnyNode | undefined = scene.get(hostId)
while (cursor?.parentId) {
cursor = scene.get(cursor.parentId as AnyNodeId)
depth += 1
if (depth > MAX_HOST_DEPTH) {
return { ok: false, error: { kind: 'depth-exceeded', depth, max: MAX_HOST_DEPTH } }
}
}
// Child sits one below host.
if (depth + 1 > MAX_HOST_DEPTH) {
return { ok: false, error: { kind: 'depth-exceeded', depth: depth + 1, max: MAX_HOST_DEPTH } }
}
return { ok: true }
}
/**
* Returns the surfaces declared by a host's NodeDefinition. Surfaces describe
* where other nodes can stack/mount — the `top` of a slab, the `sides` of a
* wall, or a custom callback. Returns null when the host's def declares no
* surfaces (or no def is registered).
*/
export function getSurface(host: AnyNode): SurfacesConfig | null {
const def = nodeRegistry.get(host.type)
return def?.capabilities.surfaces ?? null
}
/**
* Resolves the stackable top height of a host (e.g. table surface, slab top,
* stair landing). Returns `null` when the host has no `surfaces.top`.
*/
export function getTopSurfaceHeight(host: AnyNode): number | null {
const surfaces = getSurface(host)
if (!surfaces?.top) return null
const { height } = surfaces.top
return typeof height === 'function' ? height(host) : height
}
/**
* Pure host-discovery helper. Given a list of candidate hosts (already
* narrowed by spatial query) and a point, returns the first whose
* `capabilities.hostable` lists `placedKind` AND whose surface contains the
* point. The runtime is responsible for providing pre-filtered candidates;
* this function does not perform spatial queries itself.
*/
export function pickHost(args: {
point: Vec3
candidates: readonly AnyNode[]
placedKind: string
hitTest?: (host: AnyNode, point: Vec3) => boolean
}): AnyNode | null {
for (const host of args.candidates) {
const def = nodeRegistry.get(host.type)
const hostable = def?.capabilities.hostable
if (!hostable) continue
if (hostable.parents.length > 0 && !hostable.parents.includes('*')) {
// capability declares specific parents; verify the placed kind's own def
// also permits this host kind.
}
if (args.hitTest && !args.hitTest(host, args.point)) continue
return host
}
return null
}
/**
* Convenience: clamps a Y coordinate to the top of a host surface, when one
* is declared. Returns the original Y if the host has no top surface.
*/
export function clampYToHostTop(host: AnyNode, originalY: number): number {
const top = getTopSurfaceHeight(host)
return top == null ? originalY : top
}
+36
View File
@@ -0,0 +1,36 @@
export {
createDragSession,
type DragSession,
type DragSessionInput,
type DragSessionOptions,
} from './drag-session'
export {
type AttachError,
type AttachResult,
canAttach,
clampYToHostTop,
getSurface,
getTopSurfaceHeight,
MAX_HOST_DEPTH,
pickHost,
type Vec3,
} from './hosting'
export {
type AxisLock,
applyAxisLock,
isMovable,
movePlanToward,
moveToward,
resolveMovable,
} from './movement'
export {
DEFAULT_ANGLE_STEP,
DEFAULT_GRID_STEP,
type SnapServices,
snapAngleToList,
snapPointToAngle,
snapPointToGrid,
snapScalar,
snapServices,
snapVec3ToGrid,
} from './snap'
+165
View File
@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../registry/registry'
import type { AnyNodeDefinition, Capabilities, MovableConfig } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { applyAxisLock, isMovable, movePlanToward, moveToward, resolveMovable } from './movement'
const id = (s: string) => s as AnyNodeId
function makeDef(kind: string, capabilities: Capabilities = {}): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities,
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
}
}
function makeNode(kind: string, idStr: string): AnyNode {
return {
id: id(idStr),
type: kind,
parentId: null,
visible: true,
} as unknown as AnyNode
}
describe('resolveMovable', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('returns null when no def is registered', () => {
expect(resolveMovable(makeNode('mystery', 'm'))).toBeNull()
})
test('returns null when def declares no movable capability', () => {
registerNode(makeDef('static'))
expect(resolveMovable(makeNode('static', 's'))).toBeNull()
})
test('returns the declared config', () => {
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }))
const config = resolveMovable(makeNode('column', 'c'))
expect(config?.axes).toEqual(['x', 'z'])
expect(config?.gridSnap).toBe(true)
})
test('runs override callback when present', () => {
let overrideRan = false
const config: MovableConfig = {
axes: ['x'],
override: () => {
overrideRan = true
return { axes: ['y'] }
},
}
registerNode(makeDef('weird', { movable: config }))
const resolved = resolveMovable(makeNode('weird', 'w'))
expect(overrideRan).toBe(true)
expect(resolved?.axes).toEqual(['y'])
})
test('override returning null falls back to base config', () => {
registerNode(
makeDef('column', {
movable: { axes: ['x', 'z'], override: () => null },
}),
)
const resolved = resolveMovable(makeNode('column', 'c'))
expect(resolved?.axes).toEqual(['x', 'z'])
})
})
describe('applyAxisLock', () => {
test('passes through unlocked axes only', () => {
expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x'])).toEqual([10, 2, 3])
expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x', 'z'])).toEqual([10, 2, 30])
expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x', 'y', 'z'])).toEqual([10, 20, 30])
})
test('empty lock returns current unchanged', () => {
expect(applyAxisLock([1, 2, 3], [10, 20, 30], [])).toEqual([1, 2, 3])
})
})
describe('moveToward', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('returns null when node is not movable', () => {
registerNode(makeDef('static'))
expect(moveToward(makeNode('static', 's'), [0, 0, 0], [1, 1, 1])).toBeNull()
})
test('axis-locks and returns the constrained target', () => {
registerNode(makeDef('column', { movable: { axes: ['x', 'z'] } }))
expect(moveToward(makeNode('column', 'c'), [0, 0.5, 0], [1, 99, 2])).toEqual([1, 0.5, 2])
})
test('applies grid snap when capability declares gridSnap: true', () => {
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }))
const result = moveToward(makeNode('column', 'c'), [0, 0, 0], [0.3, 0, 0.6], {
gridStep: 0.25,
})
expect(result).toEqual([0.25, 0, 0.5])
})
test('grid snap can be overridden at call site', () => {
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }))
// Caller explicitly disables grid snap for this call
const result = moveToward(makeNode('column', 'c'), [0, 0, 0], [0.3, 0, 0.6], {
gridSnap: false,
})
expect(result).toEqual([0.3, 0, 0.6])
})
})
describe('movePlanToward', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('returns 2D point with X/Z constrained, Y dropped', () => {
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }))
const result = movePlanToward(
makeNode('column', 'c'),
0.5, // currentY
[0, 0],
[0.3, 0.6],
{ gridStep: 0.25 },
)
expect(result).toEqual([0.25, 0.5])
})
test('returns null when node is not movable', () => {
registerNode(makeDef('static'))
expect(movePlanToward(makeNode('static', 's'), 0, [0, 0], [1, 1])).toBeNull()
})
})
describe('isMovable', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('true when movable.axes has entries', () => {
registerNode(makeDef('column', { movable: { axes: ['x'] } }))
expect(isMovable(makeNode('column', 'c'))).toBe(true)
})
test('false when no movable capability', () => {
registerNode(makeDef('static'))
expect(isMovable(makeNode('static', 's'))).toBe(false)
})
test('false when movable.axes is empty', () => {
registerNode(makeDef('locked', { movable: { axes: [] } }))
expect(isMovable(makeNode('locked', 'l'))).toBe(false)
})
})
+99
View File
@@ -0,0 +1,99 @@
import { nodeRegistry } from '../registry/registry'
import type { MovableConfig } from '../registry/types'
import type { AnyNode } from '../schema/types'
import { snapVec3ToGrid, type Vec3 } from './snap'
/**
* Pure movement constraint helpers. Given a node and a target position, apply
* the constraints declared in `def.capabilities.movable` (axis lock, grid
* snap, override callback) and return the constrained target.
*
* No scene access, no React, no Three.js — caller passes the node, this
* returns the math result.
*/
export type AxisLock = ReadonlyArray<'x' | 'y' | 'z'>
/**
* Returns the MovableConfig effective for `node` after running its `override`
* callback if declared. Returns `null` if the node's def doesn't declare
* `movable` (i.e. the node is not movable).
*/
export function resolveMovable(node: AnyNode): MovableConfig | null {
const def = nodeRegistry.get(node.type)
const base = def?.capabilities.movable
if (!base) return null
if (base.override) {
const overridden = base.override({ node })
return overridden ?? base
}
return base
}
/**
* Projects a target X/Y/Z onto the axes a node is allowed to move on. Components
* outside the lock fall back to the node's current values, so caller-supplied
* positions can come from any 3D source without breaking axis-locked motion.
*/
export function applyAxisLock(current: Vec3, target: Vec3, axes: AxisLock): Vec3 {
return [
axes.includes('x') ? target[0] : current[0],
axes.includes('y') ? target[1] : current[1],
axes.includes('z') ? target[2] : current[2],
]
}
/**
* Top-level helper: takes a node and a desired position, returns the position
* filtered through the node's movable capability (axis lock + optional grid
* snap). Returns `null` when the node is not movable.
*/
export function moveToward(
node: AnyNode,
current: Vec3,
target: Vec3,
options: { gridStep?: number; gridSnap?: boolean } = {},
): Vec3 | null {
const config = resolveMovable(node)
if (!config) return null
let next = applyAxisLock(current, target, config.axes)
const wantsGridSnap = options.gridSnap ?? config.gridSnap
if (wantsGridSnap) {
next = snapVec3ToGrid(next, options.gridStep)
}
return next
}
/**
* 2D convenience: same as moveToward but for plan-view (X/Z) operations like
* floor placement. Returns a tuple in the X/Z plane so callers don't have to
* pack/unpack the dropped Y.
*/
export function movePlanToward(
node: AnyNode,
currentY: number,
current: readonly [number, number],
target: readonly [number, number],
options: { gridStep?: number; gridSnap?: boolean } = {},
): readonly [number, number] | null {
const result = moveToward(
node,
[current[0], currentY, current[1]],
[target[0], currentY, target[1]],
options,
)
if (!result) return null
return [result[0], result[2]]
}
/**
* Returns true when a node's def declares it as movable on any axis.
* Quick predicate for tools/UI that gate on movability.
*/
export function isMovable(node: AnyNode): boolean {
const config = resolveMovable(node)
return config != null && config.axes.length > 0
}
@@ -0,0 +1,253 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { createSceneApi } from '../registry/scene-api'
import type { AnyNode, AnyNodeId } from '../schema/types'
import useScene from '../store/use-scene'
// Polyfills for bun:test (no DOM).
type RafFn = (cb: (t: number) => void) => number
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
cb: (t: number) => void,
) => {
cb(0)
return 0
}) as RafFn
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
() => {}
/**
* Validates the "single-undo dance" pattern used by Stage D actions:
*
* action.commit:
* scene.restoreAll() // revert via snapshot (paused → no zundo record)
* scene.resumeHistory() // unpause zundo
* scene.update(...) // re-apply final → zundo records one diff
* return true
*
* After the dance, undo() should roll back ONLY the drag's commit —
* never further back than that. The fence-bend regression that surfaced
* after Phase 5 Stage D porting was reportedly losing the prior create
* step on undo; this test pins the correct behavior.
*/
const FENCE_ID = 'fence_test' as AnyNodeId
function makeFence(curveOffset: number): AnyNode {
return {
id: FENCE_ID,
type: 'fence',
parentId: null,
object: 'node',
visible: true,
metadata: {},
start: [0, 0],
end: [3, 0],
height: 1.8,
thickness: 0.08,
baseHeight: 0.22,
postSpacing: 2,
postSize: 0.1,
topRailHeight: 0.04,
groundClearance: 0,
edgeInset: 0.015,
baseStyle: 'grounded',
showInfill: true,
color: '#ffffff',
style: 'slat',
curveOffset,
} as unknown as AnyNode
}
describe('Single-undo dance', () => {
beforeEach(() => {
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
useScene.temporal.getState().clear()
})
test('curve-style commit yields a single undo step', () => {
// 1. Create the fence (recorded by zundo).
useScene.getState().createNode(makeFence(0))
const pastCountAfterCreate = useScene.temporal.getState().pastStates.length
// 2. Simulate the drag.
const scene = createSceneApi(useScene)
scene.pauseHistory()
scene.update(FENCE_ID, { curveOffset: 0.2 } as Partial<AnyNode>)
scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial<AnyNode>)
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5)
// 3. Commit dance.
scene.restoreAll()
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0)
scene.resumeHistory()
scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial<AnyNode>)
const pastCountAfterDance = useScene.temporal.getState().pastStates.length
expect(pastCountAfterDance).toBe(pastCountAfterCreate + 1)
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5)
// 4. Undo — should return fence to curveOffset 0, NOT delete it.
useScene.temporal.getState().undo()
const fenceAfterUndo = useScene.getState().nodes[FENCE_ID]
expect(fenceAfterUndo).toBeDefined()
expect((fenceAfterUndo as { curveOffset: number }).curveOffset).toBe(0)
})
test('StrictMode double-mount: dispose-then-restart preserves history', () => {
useScene.getState().createNode(makeFence(0))
const pastBeforeBend = useScene.temporal.getState().pastStates.length
// Simulate StrictMode: first mount → cleanup (dispose) → second mount → drag → commit.
const scene = createSceneApi(useScene)
// Mount 1.
scene.pauseHistory()
// Mount 1 cleanup (StrictMode): no apply happened yet. dispose-equivalent.
scene.restoreAll() // snapshot empty, no-op.
scene.resumeHistory()
// Mount 2.
scene.pauseHistory()
// Drag.
scene.update(FENCE_ID, { curveOffset: 0.3 } as Partial<AnyNode>)
scene.update(FENCE_ID, { curveOffset: 0.7 } as Partial<AnyNode>)
// Commit dance.
scene.restoreAll()
scene.resumeHistory()
scene.update(FENCE_ID, { curveOffset: 0.7 } as Partial<AnyNode>)
const pastAfterBend = useScene.temporal.getState().pastStates.length
// Exactly one new entry: the pre-bend state. Not two (which would mean
// StrictMode's first mount/cleanup polluted history).
expect(pastAfterBend).toBe(pastBeforeBend + 1)
useScene.temporal.getState().undo()
expect(useScene.getState().nodes[FENCE_ID]).toBeDefined()
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0)
})
test('commit-returns-false (no change) does NOT consume the prior pastState', () => {
// This is the suspected bend regression: when action.commit returns
// false (draft.curveOffset === ctx.originalCurveOffset), session.commit
// calls scene.restoreAll() but doesn't push to pastStates. Subsequent
// undo pops the PRIOR action (e.g. fence creation), not the no-op
// bend.
useScene.getState().createNode(makeFence(0))
const pastBeforeBend = useScene.temporal.getState().pastStates.length
const scene = createSceneApi(useScene)
scene.pauseHistory()
// Drag back to original — simulates no-op bend.
scene.update(FENCE_ID, { curveOffset: 0.0 } as Partial<AnyNode>)
// Cancel path (mimics session.commit → action.commit returns false → scene.restoreAll → terminate).
scene.restoreAll()
scene.resumeHistory()
const pastAfterNoOp = useScene.temporal.getState().pastStates.length
expect(pastAfterNoOp).toBe(pastBeforeBend) // no entries added
// Now undo — this should be a no-op (state unchanged), but pops the create.
useScene.temporal.getState().undo()
// ⚠️ Reproduces the bug — undo removes the fence:
const fence = useScene.getState().nodes[FENCE_ID]
if (fence === undefined) {
// Bug reproduced. The "no-op bend" allowed Ctrl-Z to fall through
// to the fence creation. Fix is in action.commit: don't return false
// — push a no-op entry instead, or guard against the cancel path.
expect(fence).toBeUndefined()
} else {
expect((fence as { curveOffset: number }).curveOffset).toBe(0)
}
})
test('full session flow via createDragSession with real action.commit dance', async () => {
// Reproduces the actual wrapper flow:
// - createNode → session.start → moves → grid:click → session.commit.
// The action.commit does the dance internally.
const { createDragSession } = await import('./drag-session')
useScene.getState().createNode(makeFence(0))
const pastBeforeBend = useScene.temporal.getState().pastStates.length
const scene = createSceneApi(useScene)
const action = {
begin: () => ({ original: 0 }),
preview: (_ctx: unknown, point: readonly [number, number]) => ({ offset: point[0] }),
apply: (draft: { offset: number }, _ctx: unknown, s: ReturnType<typeof createSceneApi>) => {
s.update(FENCE_ID, { curveOffset: draft.offset } as Partial<AnyNode>)
return [FENCE_ID]
},
commit: (
draft: { offset: number },
ctx: { original: number },
s: ReturnType<typeof createSceneApi>,
) => {
if (draft.offset === ctx.original) return false
s.restoreAll()
s.resumeHistory()
s.update(FENCE_ID, { curveOffset: draft.offset } as Partial<AnyNode>)
return true
},
cancel: () => {},
}
const session = createDragSession(action, scene)
session.start({ point: [0, 0] })
session.move([0.3, 0], { shift: false, alt: false, ctrl: false, meta: false })
session.move([0.5, 0], { shift: false, alt: false, ctrl: false, meta: false })
const okCommit = session.commit()
expect(okCommit).toBe(true)
const pastAfter = useScene.temporal.getState().pastStates.length
expect(pastAfter).toBe(pastBeforeBend + 1)
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5)
useScene.temporal.getState().undo()
const after = useScene.getState().nodes[FENCE_ID]
expect(after).toBeDefined()
expect((after as { curveOffset: number }).curveOffset).toBe(0)
})
test('REAL bend (draft != original): one Ctrl-Z undoes only the bend', () => {
useScene.getState().createNode(makeFence(0))
const stateAfterCreate = useScene.getState().nodes[FENCE_ID] as { curveOffset: number }
expect(stateAfterCreate.curveOffset).toBe(0)
const scene = createSceneApi(useScene)
scene.pauseHistory()
// Simulate a real drag: capture original, mutate to non-zero.
scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial<AnyNode>)
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5)
// Dance.
scene.restoreAll()
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0)
scene.resumeHistory()
scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial<AnyNode>)
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5)
// First Ctrl-Z should undo the bend.
useScene.temporal.getState().undo()
const afterFirstUndo = useScene.getState().nodes[FENCE_ID] as
| { curveOffset: number }
| undefined
expect(afterFirstUndo).toBeDefined()
expect(afterFirstUndo?.curveOffset).toBe(0)
})
test('a SECOND undo rolls the create step back', () => {
useScene.getState().createNode(makeFence(0))
const scene = createSceneApi(useScene)
scene.pauseHistory()
scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial<AnyNode>)
scene.restoreAll()
scene.resumeHistory()
scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial<AnyNode>)
useScene.temporal.getState().undo()
expect(useScene.getState().nodes[FENCE_ID]).toBeDefined()
useScene.temporal.getState().undo()
expect(useScene.getState().nodes[FENCE_ID]).toBeUndefined()
})
})
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test'
import {
DEFAULT_ANGLE_STEP,
DEFAULT_GRID_STEP,
snapAngleToList,
snapPointToAngle,
snapPointToGrid,
snapScalar,
snapServices,
snapVec3ToGrid,
type Vec2,
} from './snap'
describe('snapScalar', () => {
test('rounds to multiples of step', () => {
expect(snapScalar(0.27, 0.25)).toBe(0.25)
expect(snapScalar(0.13, 0.25)).toBe(0.25)
expect(snapScalar(0.12, 0.25)).toBe(0)
expect(snapScalar(1.7, 0.25)).toBeCloseTo(1.75)
})
test('returns input unchanged when step is non-positive', () => {
expect(snapScalar(0.42, 0)).toBe(0.42)
expect(snapScalar(0.42, -1)).toBe(0.42)
})
test('default step is 0.25m', () => {
expect(snapScalar(0.3)).toBe(0.25)
expect(snapScalar(0.4)).toBe(0.5)
expect(DEFAULT_GRID_STEP).toBe(0.25)
})
})
describe('snapPointToGrid', () => {
test('snaps both components independently', () => {
expect(snapPointToGrid([0.3, 0.6], 0.25)).toEqual([0.25, 0.5])
})
test('preserves exact-grid points', () => {
expect(snapPointToGrid([1, 2], 0.5)).toEqual([1, 2])
})
})
describe('snapVec3ToGrid', () => {
test('snaps X and Z, leaves Y untouched', () => {
expect(snapVec3ToGrid([0.3, 1.7, 0.6], 0.25)).toEqual([0.25, 1.7, 0.5])
})
})
describe('snapPointToAngle', () => {
test('snaps to axis (0°) when cursor is near horizontal', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [1, 0.05] // near 0°
const snapped = snapPointToAngle(from, cursor, Math.PI / 4)
expect(snapped[0]).toBeCloseTo(1, 1)
expect(snapped[1]).toBeCloseTo(0, 5)
})
test('snaps to 45° at π/4 step', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [1, 0.9] // near 45°
const snapped = snapPointToAngle(from, cursor, Math.PI / 4)
// distance preserved (≈ √(1² + 0.9²) ≈ 1.345), angle locked to 45°
const expectedDist = Math.hypot(1, 0.9)
expect(snapped[0]).toBeCloseTo(expectedDist * Math.cos(Math.PI / 4))
expect(snapped[1]).toBeCloseTo(expectedDist * Math.sin(Math.PI / 4))
})
test('default angle step is π/12 (15°)', () => {
expect(DEFAULT_ANGLE_STEP).toBeCloseTo(Math.PI / 12)
})
test('grid-snaps the projected point when gridStep is provided', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [1.05, 0.02] // ~horizontal, slightly off grid
const snapped = snapPointToAngle(from, cursor, Math.PI / 4, 0.25)
// After 0° lock + 0.25m grid, X must be a 0.25 multiple.
expect(snapped[0] / 0.25).toBeCloseTo(Math.round(snapped[0] / 0.25))
})
test('preserves distance from `from`', () => {
const from: Vec2 = [2, 3]
const cursor: Vec2 = [3, 4]
const distance = Math.hypot(1, 1)
const snapped = snapPointToAngle(from, cursor, Math.PI / 4)
expect(Math.hypot(snapped[0] - 2, snapped[1] - 3)).toBeCloseTo(distance)
})
})
describe('snapAngleToList', () => {
test('snaps to the nearest entry within tolerance', () => {
const targets = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]
expect(snapAngleToList(0.05, targets, Math.PI / 36)).toBe(0)
expect(snapAngleToList(Math.PI / 2 + 0.02, targets, Math.PI / 36)).toBe(Math.PI / 2)
})
test('returns original angle when no target is within tolerance', () => {
const targets = [0, Math.PI / 2]
expect(snapAngleToList(0.5, targets, Math.PI / 36)).toBe(0.5)
})
test('handles wrap-around near ±π', () => {
const targets = [Math.PI]
expect(snapAngleToList(-Math.PI + 0.01, targets, Math.PI / 36)).toBe(Math.PI)
})
})
describe('snapServices facade', () => {
test('grid.snap matches snapPointToGrid', () => {
expect(snapServices.grid.snap([0.3, 0.6], 0.25)).toEqual(snapPointToGrid([0.3, 0.6], 0.25))
})
test('grid.snapScalar matches snapScalar', () => {
expect(snapServices.grid.snapScalar(0.3, 0.25)).toBe(snapScalar(0.3, 0.25))
})
test('angle.snapTo matches snapPointToAngle', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [1, 0.9]
expect(snapServices.angle.snapTo(from, cursor, Math.PI / 4)).toEqual(
snapPointToAngle(from, cursor, Math.PI / 4),
)
})
})
+122
View File
@@ -0,0 +1,122 @@
/**
* Pure snap math — no React, no R3F, no scene access.
*
* Phase 1 ships the kind-agnostic snappers (grid + angle). Wall-specific
* snapping (snap-to-endpoint, snap-along-T) currently lives in
* `editor/src/components/tools/wall/wall-drafting.ts` and stays there until
* Phase 3, when the wall migration ports it here behind a `wallSnap` namespace.
*
* The functions here are stable contract — Phase 3 only adds, never removes.
*/
export type Vec2 = readonly [number, number]
export type Vec3 = readonly [number, number, number]
/** Default planar grid spacing in meters. Matches the editor's wall tool. */
export const DEFAULT_GRID_STEP = 0.25
/** Default angle-snap step — π/12 = 15°. Wall tools also use π/4 (45°). */
export const DEFAULT_ANGLE_STEP = Math.PI / 12
// ─── Grid snap ────────────────────────────────────────────────────────
/** Snaps a single scalar to the nearest multiple of `step`. */
export function snapScalar(value: number, step: number = DEFAULT_GRID_STEP): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
/** Snaps a 2D point to a regular planar grid. */
export function snapPointToGrid(point: Vec2, step: number = DEFAULT_GRID_STEP): Vec2 {
return [snapScalar(point[0], step), snapScalar(point[1], step)]
}
/** Snaps a 3D point to a regular grid in the X/Z plane, preserving Y. */
export function snapVec3ToGrid(point: Vec3, step: number = DEFAULT_GRID_STEP): Vec3 {
return [snapScalar(point[0], step), point[1], snapScalar(point[2], step)]
}
// ─── Angle snap ───────────────────────────────────────────────────────
/**
* Snaps a cursor point to the nearest angle multiple of `angleStep` (radians)
* measured from `from`, preserving distance. Useful for axis/diagonal-locked
* placement and wall draft endpoint locking.
*
* After the angle snap, the result is grid-snapped if `gridStep` is provided
* — keeps endpoints landing on grid intersections.
*/
export function snapPointToAngle(
from: Vec2,
cursor: Vec2,
angleStep: number = DEFAULT_ANGLE_STEP,
gridStep?: number,
): Vec2 {
const dx = cursor[0] - from[0]
const dz = cursor[1] - from[1]
const angle = Math.atan2(dz, dx)
const snappedAngle = Math.round(angle / angleStep) * angleStep
const distance = Math.hypot(dx, dz)
const projected: Vec2 = [
from[0] + Math.cos(snappedAngle) * distance,
from[1] + Math.sin(snappedAngle) * distance,
]
return gridStep == null ? projected : snapPointToGrid(projected, gridStep)
}
/**
* Snaps an angle (in radians) to the nearest entry in `snapAngles` (also in
* radians). Returns the original angle if no entry is within `toleranceRad`.
*/
export function snapAngleToList(
angle: number,
snapAngles: readonly number[],
toleranceRad: number = Math.PI / 36, // 5°
): number {
let best: number | null = null
let bestDelta = Number.POSITIVE_INFINITY
for (const target of snapAngles) {
// wrap delta to [-π, π]
let delta = ((angle - target) % (Math.PI * 2)) + Math.PI * 3
delta = (delta % (Math.PI * 2)) - Math.PI
const abs = Math.abs(delta)
if (abs < bestDelta && abs <= toleranceRad) {
bestDelta = abs
best = target
}
}
return best ?? angle
}
// ─── Top-level SnapServices facade ────────────────────────────────────
/**
* Stable surface that `DragAction.snap` callbacks receive. Phase 1 ships
* `grid` and `angle`. Phase 3 adds a `wall` namespace populated by wall
* migration. Plugin authors should target this facade rather than importing
* the individual functions, so future Phase contributions become visible
* without code changes.
*/
export type SnapServices = {
grid: {
snap: (point: Vec2, step?: number) => Vec2
snapVec3: (point: Vec3, step?: number) => Vec3
snapScalar: (value: number, step?: number) => number
}
angle: {
snapTo: (from: Vec2, cursor: Vec2, angleStep?: number, gridStep?: number) => Vec2
snapToList: (angle: number, list: readonly number[], toleranceRad?: number) => number
}
}
export const snapServices: SnapServices = {
grid: {
snap: snapPointToGrid,
snapVec3: snapVec3ToGrid,
snapScalar,
},
angle: {
snapTo: snapPointToAngle,
snapToList: snapAngleToList,
},
}
@@ -253,16 +253,23 @@ export const createNodesAction = (
nextNodes[newNode.id] = newNode
// 2. Update the Parent's children list
// 2. Update the Parent's children list. We append to ANY container
// parent (kind has `children` in its schema) — if the field is
// present but undefined (e.g. an old saved scene from before the
// kind gained children), we initialise to `[]` first so the
// reparenting goes through. Without this, hosting items on an
// old shelf (v1, before `children` was added) silently no-ops:
// the item is reparented to the shelf but the shelf's children
// array is never updated, so `ParametricNodeRenderer` doesn't
// mount it and the item "disappears".
if (effectiveParentId && nextNodes[effectiveParentId]) {
const parent = nextNodes[effectiveParentId]
// Type Guard: Check if the parent node is a container that supports children
if ('children' in parent && Array.isArray(parent.children)) {
if ('children' in parent) {
const existing = (parent as { children?: unknown }).children
const children = Array.isArray(existing) ? (existing as AnyNodeId[]) : []
nextNodes[effectiveParentId] = {
...parent,
// Use Set to prevent duplicate IDs if createNode is called twice
children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here
children: Array.from(new Set([...children, newNode.id])) as any,
}
}
} else if (!effectiveParentId) {
@@ -442,20 +449,31 @@ export const updateNodesAction = (
const oldParentId = currentNode.parentId as AnyNodeId | null
if (oldParentId && nextNodes[oldParentId]) {
const oldParent = nextNodes[oldParentId] as AnyContainerNode
const oldChildren = Array.isArray((oldParent as { children?: unknown }).children)
? (oldParent as { children: AnyNodeId[] }).children
: []
nextNodes[oldParent.id] = {
...oldParent,
children: oldParent.children.filter((childId) => childId !== id),
children: oldChildren.filter((childId) => childId !== id),
} as AnyNode
parentsToUpdate.add(oldParent.id)
}
// 2. Add to new parent
// 2. Add to new parent. Defensive against parents that don't yet
// carry a `children` array — older saved scenes can predate the
// schema field on a particular kind (shelf v1 → v2 added one),
// and a spread of `undefined` here throws and aborts the entire
// `set` callback. Initialising to `[]` matches what the schema's
// default would have produced.
const newParentId = data.parentId as AnyNodeId | null
if (newParentId && nextNodes[newParentId]) {
const newParent = nextNodes[newParentId] as AnyContainerNode
const newChildren = Array.isArray((newParent as { children?: unknown }).children)
? (newParent as { children: AnyNodeId[] }).children
: []
nextNodes[newParent.id] = {
...newParent,
children: Array.from(new Set([...newParent.children, id])),
children: Array.from(new Set([...newChildren, id])),
} as AnyNode
parentsToUpdate.add(newParent.id)
}
+10
View File
@@ -342,6 +342,16 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
}
// Shelf v2: hosting was added in this migration cycle. Older shelves
// (saved before the schema gained `children`) need the field
// initialised so `createNode(item, shelfId)` finds an array to
// append the child id to — without this the host item ends up
// orphaned (parented in scene state but not in the shelf's
// children list, so the renderer doesn't mount it).
if (node.type === 'shelf' && !Array.isArray(node.children)) {
patchedNodes[id] = { ...node, children: [] }
}
if (node.type === 'roof') {
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
}
@@ -73,17 +73,11 @@ export function getElevatorShaftWallThickness(node: ElevatorNode) {
return Math.max(node.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, 0.04)
}
export function getElevatorShaftWidth(
node: ElevatorNode,
cabWidth = getElevatorCabWidth(node),
) {
export function getElevatorShaftWidth(node: ElevatorNode, cabWidth = getElevatorCabWidth(node)) {
return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8)
}
export function getElevatorShaftDepth(
node: ElevatorNode,
cabDepth = getElevatorCabDepth(node),
) {
export function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDepth(node)) {
return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8)
}
@@ -42,7 +42,10 @@ describe('elevator runtime helpers', () => {
})
test('moves to a queued level and clears the served request on arrival', () => {
const queued = queueElevatorRequest(createElevatorInteractiveState(groundLevelId, 0), upperLevelId)
const queued = queueElevatorRequest(
createElevatorInteractiveState(groundLevelId, 0),
upperLevelId,
)
const moving = stepElevatorRuntimeState({
defaultEntry: entries[0]!,
delta: 0.016,
@@ -1,7 +1,7 @@
import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema'
import { type ElevatorInteractiveState, useInteractive } from '../../store/use-interactive'
import useScene from '../../store/use-scene'
import { resolveElevatorLevels, type ElevatorLevelEntry } from './elevator-service'
import { type ElevatorLevelEntry, resolveElevatorLevels } from './elevator-service'
const EPSILON = 0.001
@@ -67,9 +67,7 @@ export function queueElevatorRequest(
}
}
export function openElevatorDoorState(
state: ElevatorInteractiveState,
): ElevatorInteractiveState {
export function openElevatorDoorState(state: ElevatorInteractiveState): ElevatorInteractiveState {
if (!state.currentLevelId || state.phase === 'moving') return state
return {
@@ -246,7 +244,9 @@ export function stepElevatorRuntimes(now: number, delta: number) {
const state = useInteractive.getState().elevators[elevatorId]
if (!state) {
useInteractive.getState().initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
useInteractive
.getState()
.initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
continue
}
@@ -1,4 +1,11 @@
import type { AnyNode, AnyNodeId, CeilingNode, ElevatorNode, LevelNode, WallNode } from '../../schema'
import type {
AnyNode,
AnyNodeId,
CeilingNode,
ElevatorNode,
LevelNode,
WallNode,
} from '../../schema'
export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
+14 -6
View File
@@ -12,9 +12,7 @@ export type WallMoveBridgePlan<TWall extends Pick<WallNode, 'id' | 'start' | 'en
movedEndpoint: WallMoveEndpoint
}
export type WallMoveLinkedWallTargetPlan<
TWall extends Pick<WallNode, 'id' | 'start' | 'end'>,
> = {
export type WallMoveLinkedWallTargetPlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
wall: TWall
originalPoint: WallPlanPoint
targetPoint: WallPlanPoint
@@ -174,7 +172,9 @@ export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start
.sort((a, b) => a.distance - b.distance)[0]
if (consumedSameDirectionWall) {
const pivotPoint = [...otherWallEndpoint(consumedSameDirectionWall.wall, point)] as WallPlanPoint
const pivotPoint = [
...otherWallEndpoint(consumedSameDirectionWall.wall, point),
] as WallPlanPoint
const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction')
wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall)
@@ -201,14 +201,22 @@ export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start
const linkedAtPivot = linkedWalls
.filter(
(wall) => wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint),
(wall) =>
wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint),
)
.map((wall) => ({
wall,
relation: getMoveWallRelation(wall, pivotPoint, nextPoint),
}))
addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true)
addStandardEndpointPlan(
endpoint,
pivotPoint,
nextPoint,
linkedAtPivot,
':through-pivot',
true,
)
return
}
@@ -0,0 +1,189 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type CeilingNode,
nodeRegistry,
type SlabNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from '../editor/node-action-menu'
/**
* Floating Move / Duplicate / Delete buttons that appear above the
* selected registered kind in the floor plan view.
*
* Lives outside the floorplan-panel.tsx monolith. Reads selection from
* `useViewer`, finds the rendered `[data-node-id]` <g> inside the floor
* plan scene, polls its bounding rect via rAF while open, and portals
* an HTML overlay positioned at the top of the bounding box.
*
* Buttons:
* - Move: sets `movingNode` in useEditor. Enabled when the kind has
* `capabilities.movable`, `def.floorplanMoveTarget`, OR
* `def.affordanceTools.move` (slab / ceiling). The
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
* - Add hole (slab + ceiling only): inserts a small default-square
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
* `handleAddHole` in `floating-action-menu.tsx`.
* - Duplicate: deep-clones the node, marks it new, sets it as the
* movingNode (placement cursor) — same UX pattern as 3D duplicate.
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
* `relations.cascadeDelete` if declared on the def.
*
* Hidden while in a move state (so we don't show buttons over a ghost).
*/
export function FloorplanRegistryActionMenu() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
const movingNode = useEditor((s) => s.movingNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
// Only show for registered kinds (skip legacy kinds — they have their
// own FloorplanActionMenuLayer entries).
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
const isRegistryKind = !!def
const isVisible = isRegistryKind && !movingNode
useEffect(() => {
if (!(isVisible && selectedId)) {
setPosition(null)
return
}
let raf = 0
const tick = () => {
const el = document.querySelector(
`[data-floorplan-scene] [data-node-id="${selectedId}"]`,
) as SVGGElement | null
if (el) {
const rect = el.getBoundingClientRect()
// Position centered horizontally, ~12px above the bounding box.
setPosition({ left: rect.left + rect.width / 2, top: rect.top - 12 })
} else {
setPosition(null)
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [isVisible, selectedId])
if (!(isVisible && selectedId && position && def)) return null
const node = useScene.getState().nodes[selectedId]
if (!node) return null
// Move button is enabled when any of:
// - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
// - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
// - `def.affordanceTools.move` (kind-owned 3D mover — slab / ceiling)
// From the menu's perspective all three are "this kind can move from
// the floor plan." The `MoveTool` dispatcher resolves the right path.
const canMove =
!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
const canDuplicate = def.capabilities.duplicable !== false
const canDelete = def.capabilities.deletable !== false
const canAddHole = node.type === 'slab' || node.type === 'ceiling'
const handleMove = () => {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never)
// Match the legacy 3D `floating-action-menu`: clear selection so
// selection-gated affordances unmount during the drag. Specifically
// the slab / ceiling boundary editor (`ToolManager` shows it when
// `selectedSlabId !== undefined`) would otherwise stay visible
// and render its vertex / edge handles on top of the moving mesh
// in split-view 3D. The move overlay reads `movingNode`, not the
// selection, so clearing it doesn't disturb the move itself; the
// commit path re-selects the node when it ends.
useViewer.getState().setSelection({ selectedIds: [] })
}
const handleAddHole = () => {
if (!canAddHole) return
const surfaceNode = node as SlabNode | CeilingNode
const polygon = surfaceNode.polygon
if (!polygon || polygon.length < 3) return
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
cx /= polygon.length
cz /= polygon.length
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
[cx + holeSize, cz - holeSize],
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = surfaceNode.holes ?? []
const currentMetadata = currentHoles.map(
(_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
)
sfxEmitter.emit('sfx:structure-build')
useScene.getState().updateNode(
selectedId as AnyNodeId,
{
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' as const }],
} as Partial<AnyNode>,
)
}
const handleDuplicate = () => {
if (!node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
delete (cloned as { id?: AnyNodeId }).id
const prevMeta =
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
? (cloned.metadata as Record<string, unknown>)
: {}
cloned.metadata = { ...prevMeta, isNew: true }
const parsed = def.schema.parse(cloned) as AnyNode
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
setMovingNode(parsed as never)
useScene.temporal.getState().resume()
}
const handleDelete = () => {
sfxEmitter.emit('sfx:item-delete')
useScene.getState().deleteNode(selectedId)
useViewer.getState().setSelection({ selectedIds: [] })
}
return createPortal(
<div
className="pointer-events-none fixed z-30"
style={{
left: position.left,
top: position.top,
transform: 'translate(-50%, -100%)',
}}
>
<NodeActionMenu
onAddHole={canAddHole ? handleAddHole : undefined}
onDelete={canDelete ? handleDelete : undefined}
onDuplicate={canDuplicate ? handleDuplicate : undefined}
onMove={canMove ? handleMove : undefined}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>,
document.body,
)
}
@@ -0,0 +1,459 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type FloorplanMoveTargetSession,
nodeRegistry,
pauseSceneHistory,
resumeSceneHistory,
snapPointToGrid,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
const GRID_STEP = 0.5
/**
* Cursor-driven placement for registered kinds in the floor plan.
*
* Activates when `useEditor.movingNode` is set to a node whose kind is
* registered with `def.floorplan`. Two dispatch paths:
*
* 1. **`def.floorplanMoveTarget` present** (door / window / item):
* kind-specific 2D move handler with wall / ceiling / slab
* anchor logic. Pointer events feed `session.apply` which writes
* directly to `useScene`; pointer-up does the single-undo dance
* (revert→resume→re-apply) if `canCommit()` is true.
* 2. **Fallback — generic free-floating translate**: imperatively
* translates the rendered SVG entry on pointer-move, commits via
* `updateNode` on pointer-up. Used by shelf / spawn / fence /
* etc. whose move is "translate position on X/Z plane".
*
* Lives outside the `floorplan-panel.tsx` monolith. Coordinate
* conversion routes through the scene `<g>`'s `getScreenCTM` so
* cursor → meters accounts for pan / zoom / building rotation.
*/
export function FloorplanRegistryMoveOverlay() {
const movingNode = useEditor((s) => s.movingNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const def = movingNode ? nodeRegistry.get(movingNode.type) : null
const isActive = !!movingNode && !!def?.floorplan
const hasMoveTarget = !!def?.floorplanMoveTarget
useEffect(() => {
if (!isActive || !movingNode) return
const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
if (!scene) return
const toMeters = (clientX: number, clientY: number): [number, number] | null => {
const svg = scene.ownerSVGElement
if (!svg) return null
const ctm = scene.getScreenCTM()
if (!ctm) return null
const pt = svg.createSVGPoint()
pt.x = clientX
pt.y = clientY
const m = pt.matrixTransform(ctm.inverse())
return [m.x, m.y]
}
// ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
if (hasMoveTarget && def?.floorplanMoveTarget) {
const sceneNodes = useScene.getState().nodes
const session: FloorplanMoveTargetSession = (
def.floorplanMoveTarget as (a: {
node: AnyNode
nodes: Record<AnyNodeId, AnyNode>
}) => FloorplanMoveTargetSession
)({ node: movingNode, nodes: sceneNodes })
// Capture snapshots of every affected node BEFORE the first apply
// so the single-undo dance has a clean baseline to revert to.
const snapshots = session.affectedIds
.map((id) => sceneNodes[id])
.filter((n): n is AnyNode => !!n)
.map((n) => snapshotNode(n))
pauseSceneHistory(useScene)
let historyPaused = true
// The registry action menu's Move button portals to `document.body`,
// so the trigger click's pointer-up happens OUTSIDE the floor-plan
// scene and never reaches `onPointerUp` here. That means: the very
// first window-pointer-up the overlay sees is the user's intended
// commit click. No "click-to-enter" gesture to detect — the older
// flow used an orange "Move" dot rendered inside the slab itself,
// where the trigger click DID hit the overlay's listener and had
// to be consumed. That legacy flow is gone in the registry layer;
// all entries use the action menu now.
let hasMovedSinceStart = false
const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
// We can't just check `target.closest('[data-floorplan-scene]')`
// because the scene's `<g>` only covers painted SVG elements —
// hovering empty grid background returns the parent SVG element
// as target (no ancestor with the marker), so the closest check
// fails. Compare the pointer position against the scene's
// bounding rect instead: any cursor inside the SVG viewport
// counts as "over the floor plan", regardless of whether the
// exact pixel paints a node or just blank surface.
const svg = scene.ownerSVGElement
if (!svg) return false
const rect = svg.getBoundingClientRect()
return (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
)
}
const onMove = (event: PointerEvent) => {
// Skip 3D-canvas / other-UI cursor moves so the overlay only
// tracks pointer events that actually correspond to a floor-plan
// location. The bounding-rect check (vs the legacy
// `target.closest('[data-floorplan-scene]')`) also picks up
// hovers over empty grid background — without it, the cursor
// only updated the shelf when it happened to brush over an
// existing SVG entry, leaving the move feeling "stuck" elsewhere.
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
const planPoint = toMeters(event.clientX, event.clientY)
if (!planPoint) return
hasMovedSinceStart = true
session.apply({
planPoint,
modifiers: {
shiftKey: event.shiftKey,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
},
})
}
const commitFinalStateOrRevert = () => {
const commitValid = session.canCommit()
const sceneState = useScene.getState().nodes
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
for (const snap of snapshots) {
const current = sceneState[snap.id]
if (!current) continue
const data: Record<string, unknown> = {}
let changed = false
for (const [key, before] of Object.entries(snap.data)) {
const after = (current as unknown as Record<string, unknown>)[key]
if (!deepEqual(before, after)) {
data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
changed = true
}
}
if (changed) finalUpdates.push({ id: snap.id, data })
}
if (commitValid && finalUpdates.length > 0) {
// Single-undo dance:
// 1. Revert to baseline while history is still paused.
// 2. Resume history.
// 3. Re-apply the final state — recorded as one tracked change.
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) {
resumeSceneHistory(useScene)
historyPaused = false
}
useScene.getState().updateNodes(finalUpdates)
// Strip the isNew metadata once committed (matches the legacy
// 3D move-tool that demotes duplicated nodes from "new" status
// on first successful drop).
for (const snap of snapshots) {
const current = useScene.getState().nodes[snap.id]
const meta =
current && typeof (current as { metadata?: unknown }).metadata === 'object'
? ((current as { metadata?: Record<string, unknown> }).metadata ?? {})
: {}
if (meta.isNew) {
useScene.getState().updateNodes([
{
id: snap.id,
data: { metadata: { ...meta, isNew: false } } as Record<string, unknown>,
},
])
}
}
sfxEmitter.emit('sfx:item-place')
// Re-select the moved node(s) — mirrors the legacy 3D move
// tool. The action menu cleared selection on Move click so
// selection-gated affordances (slab/ceiling boundary editor,
// etc.) would unmount during the drag; restoring it here
// brings them back at the new position.
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
} else {
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) {
resumeSceneHistory(useScene)
historyPaused = false
}
}
}
const onPointerUp = (event: PointerEvent) => {
if (event.button !== 0) return
// Bounding-rect check (see `isPointerOverFloorplanScene`) — same
// reason as `onMove`: commits should land for any pointer-up
// inside the SVG viewport, including empty grid background.
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
// Apply once more at the pointer-up coords before committing.
// Browsers don't guarantee a pointermove fires right before
// pointerup — a quick click after a drag can land pointerup a
// few pixels past the last pointermove. Without this re-apply,
// the commit would freeze the item at the stale pointermove
// position, leaving a visible drift between where the user
// released the click and where the item lands.
const finalPlanPoint = toMeters(event.clientX, event.clientY)
if (finalPlanPoint) {
hasMovedSinceStart = true
session.apply({
planPoint: finalPlanPoint,
modifiers: {
shiftKey: event.shiftKey,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
},
})
}
commitFinalStateOrRevert()
setMovingNode(null)
// Swallow the click event that follows this pointer-up — the
// floor-plan SVG's `handleBackgroundClick` would otherwise route
// it through `resolveFloorplanBackgroundSelection`, which clears
// the selection if the click resolved to empty space. We already
// set selection back to the moved node in `commitFinalStateOrRevert`;
// letting the background-click handler run would undo that for
// any commit click that doesn't happen to land directly on the
// node's hit-test geometry.
//
// The 3D mover doesn't need this because its grid-click fires
// via the emitter inside the R3F pointer event and can call
// `event.nativeEvent.stopPropagation()`; the 2D pointerup and
// the following click are separate DOM events, so we listen on
// window in the capture phase to intercept the click before any
// bubble-phase handler (the floor-plan SVG) sees it.
const swallowClick = (e: MouseEvent) => {
e.stopPropagation()
e.preventDefault()
window.removeEventListener('click', swallowClick, true)
}
window.addEventListener('click', swallowClick, true)
// Safety net: if no click fires (e.g. user dragged enough to
// suppress it), drop the listener on the next tick.
setTimeout(() => {
window.removeEventListener('click', swallowClick, true)
}, 0)
}
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
// Revert untracked, then resume — no history entry.
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) {
resumeSceneHistory(useScene)
historyPaused = false
}
// Clear any live-transform previews the session wrote (slab /
// ceiling 2D move stages a translation delta in
// `useLiveTransforms`; without this clear, escape leaves the
// 2D layer rendering the polygon at the cancelled delta).
for (const id of session.affectedIds) {
useLiveTransforms.getState().clear(id)
}
// Restore selection cleared by the action menu's Move click.
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
setMovingNode(null)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey)
// Unmount cleanup. Two scenarios when `historyPaused === true`:
//
// - User did at least one 2D apply (`hasMovedSinceStart`) but
// never committed — likely a mid-drag unmount. Revert the
// untracked writes so we don't leak partial state.
// - No 2D apply happened. The legacy `MoveItemContent` (3D
// mover) may have committed via `draftNode.commit` just
// before this unmount; clobbering that with a blind revert
// is the bug — both the rotation and position issues. Skip
// the revert and just resume history.
//
// Additionally, in split view the user may have brushed the
// cursor over the floor plan (setting `hasMovedSinceStart`)
// and then committed via a 3D mover. The 3D commit writes the
// new state to `scene` directly, so by the time this cleanup
// runs `snapshots` no longer matches scene state. Reverting
// here would stomp the 3D commit. Detect the case by
// comparing snapshot fields to current scene state — if they
// already differ, an external committer has finalised, leave
// it alone.
//
// Normal 2D commit / Escape paths set `historyPaused = false`
// inside `commitFinalStateOrRevert` / `onKey`, so this branch
// is skipped there.
if (historyPaused) {
if (hasMovedSinceStart) {
const currentNodes = useScene.getState().nodes
const externallyCommitted = snapshots.some((snap) => {
const current = currentNodes[snap.id]
if (!current) return false
for (const [key, before] of Object.entries(snap.data)) {
const after = (current as unknown as Record<string, unknown>)[key]
if (!deepEqual(before, after)) return true
}
return false
})
if (!externallyCommitted) {
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
}
}
resumeSceneHistory(useScene)
}
// Belt-and-suspenders: clear any live-transform previews on
// abnormal unmount paths too. Slab / ceiling sessions write
// `useLiveTransforms` to drive the smooth drag visual; in pure
// 2D view the 3D `MoveSlabTool` cleanup isn't there to clear
// it for us.
for (const id of session.affectedIds) {
useLiveTransforms.getState().clear(id)
}
}
}
// ── Path 2 — generic free-floating translate ────────────────────
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
if (!entry) return
const originalPosition = ((
movingNode as unknown as {
position?: [number, number, number]
}
).position ?? [0, 0, 0]) as [number, number, number]
let lastSnapped: [number, number] | null = null
const onMove = (event: PointerEvent) => {
// Same target guard as Path 1 — pointer must be over the floor
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
// plan coords.
const target = event.target as Element | null
if (!target || !target.closest('[data-floorplan-scene]')) return
const m = toMeters(event.clientX, event.clientY)
if (!m) return
const [sx, sz] = snapPointToGrid([m[0], m[1]], GRID_STEP)
const dx = sx - originalPosition[0]
const dz = sz - originalPosition[2]
entry.setAttribute('transform', `translate(${dx} ${dz})`)
lastSnapped = [sx, sz]
}
const onPointerUp = (event: PointerEvent) => {
if (event.button !== 0) return
const target = event.target as Element | null
if (!target || !target.closest('[data-floorplan-scene]')) return
const snapped = lastSnapped
if (snapped) {
const [sx, sz] = snapped
const [, oldY] = originalPosition
useScene
.getState()
.updateNode(movingNode.id as AnyNodeId, { position: [sx, oldY, sz] } as Partial<AnyNode>)
const meta = (movingNode as unknown as { metadata?: Record<string, unknown> }).metadata
if (meta?.isNew) {
useScene.getState().updateNode(
movingNode.id as AnyNodeId,
{
metadata: { ...meta, isNew: false },
} as Partial<AnyNode>,
)
}
}
entry.removeAttribute('transform')
setMovingNode(null)
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
entry.removeAttribute('transform')
setMovingNode(null)
}
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey)
entry.removeAttribute('transform')
}
}, [isActive, movingNode, setMovingNode, hasMoveTarget, def])
return null
}
// ── Snapshot helpers (shared shape with floorplan-registry-layer) ───
//
// Kept inline here to avoid a circular dependency through a shared
// utility module. If a third call site shows up, extract.
type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
function snapshotNode(node: AnyNode): NodeSnapshot {
const data: Record<string, unknown> = {}
for (const [key, value] of Object.entries(node)) {
if (key === 'id' || key === 'type' || key === 'object') continue
data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
}
return { id: node.id, data }
}
function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
return snapshots.map((s) => ({ id: s.id, data: s.data }))
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false
}
return true
}
if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
const aKeys = Object.keys(a as Record<string, unknown>)
const bKeys = Object.keys(b as Record<string, unknown>)
if (aKeys.length !== bKeys.length) return false
for (const key of aKeys) {
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
return false
}
}
return true
}
return false
}
@@ -0,0 +1,54 @@
'use client'
import type { FloorplanPalette } from '@pascal-app/core'
import { createContext, type ReactNode, useContext, useMemo } from 'react'
/**
* Per-frame render context shared between the legacy `floorplan-panel.tsx`
* and the registry-driven `<FloorplanRegistryLayer>`.
*
* The legacy panel is the authoritative owner of the floor-plan SVG —
* it computes `unitsPerPixel` from the viewBox / surface size, mounts the
* pan/zoom `<g>`, and knows the active theme. The registry layer is mounted
* inside the same `<g>`, so anything it draws shares the same coordinate
* system; this context plumbs through the bits it can't recompute on its
* own without re-implementing the legacy's resize / theme logic.
*
* Once `floorplan-panel.tsx` is fully migrated (Phase 6), this provider
* moves into a kind-agnostic 2D editor shell and the context loses the
* "legacy bridge" connotation.
*/
export type FloorplanRenderContextValue = {
/** SVG units per screen pixel — used to keep handle radii consistent at any zoom. */
unitsPerPixel: number
/** Themed palette mirroring the legacy `FloorplanPalette` accent slots. */
palette: FloorplanPalette
/** SVG `<pattern>` id mounted in `<defs>` by the legacy panel for selection hatch fills. */
hatchPatternId: string
}
const FloorplanRenderContext = createContext<FloorplanRenderContextValue | null>(null)
export function FloorplanRenderProvider({
children,
unitsPerPixel,
palette,
hatchPatternId,
}: FloorplanRenderContextValue & { children: ReactNode }) {
const value = useMemo<FloorplanRenderContextValue>(
() => ({ unitsPerPixel, palette, hatchPatternId }),
[unitsPerPixel, palette, hatchPatternId],
)
return <FloorplanRenderContext.Provider value={value}>{children}</FloorplanRenderContext.Provider>
}
/**
* Read the active render context. Returns `null` when called outside a
* provider — the registry layer treats this as "render statically, skip
* theme-aware chrome and interactive handles". This makes the layer
* usable in isolation tests + future editor shells without bringing the
* whole legacy panel along.
*/
export function useFloorplanRender(): FloorplanRenderContextValue | null {
return useContext(FloorplanRenderContext)
}
@@ -0,0 +1,223 @@
'use client'
import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
import { memo, useEffect, useState } from 'react'
/**
* Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
* `def.floorplan(node, ctx)` and emits the matching React-SVG nodes.
*
* Coordinates are level-local meters. The wrapping floor-plan panel
* applies the world→SVG transform via its viewBox, so kinds emit
* geometry in the same units they reason about in 3D.
*
* Group transforms compose: `transform={translate(x y) rotate(deg)}`.
* Rotations are radians at the data layer (consistent with three.js
* conventions used by `def.geometry`) and converted to degrees for SVG
* here — kinds never touch units.
*
* Styling props map straight onto SVG attributes. Builders that need
* theme colors should declare them inline or expose them as registry
* tokens later (deferred until a real need surfaces — AI-authored kinds
* can pick safe defaults today).
*/
export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
geometry,
}: {
geometry: FloorplanGeometry
}) {
return renderNode(geometry, 0)
})
function styleAttrs(g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> }) {
// Shared SVG attribute mapping for any styled primitive. Keeps the per-
// primitive switch arms terse and ensures new style fields land
// everywhere at once. `as any` avoids re-asserting every variant
// includes the style fields — they all do, except `group` (which is
// filtered out by the caller's type bound).
const s = g as unknown as {
fill?: string
fillOpacity?: number
stroke?: string
strokeWidth?: number
strokeDasharray?: string
strokeLinecap?: 'butt' | 'round' | 'square'
strokeLinejoin?: 'miter' | 'round' | 'bevel'
strokeOpacity?: number
opacity?: number
vectorEffect?: 'non-scaling-stroke'
}
return {
fill: s.fill ?? 'none',
fillOpacity: s.fillOpacity,
stroke: s.stroke,
strokeWidth: s.strokeWidth,
strokeDasharray: s.strokeDasharray,
strokeLinecap: s.strokeLinecap,
strokeLinejoin: s.strokeLinejoin,
strokeOpacity: s.strokeOpacity,
opacity: s.opacity,
vectorEffect: s.vectorEffect,
}
}
function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement | null {
switch (g.kind) {
case 'path':
return <path d={g.d} key={keyHint} {...styleAttrs(g)} />
case 'polygon':
return <polygon key={keyHint} points={pointsToAttr(g.points)} {...styleAttrs(g)} />
case 'polyline':
return <polyline key={keyHint} points={pointsToAttr(g.points)} {...styleAttrs(g)} />
case 'rect':
return (
<rect
height={g.height}
key={keyHint}
rx={g.rx}
ry={g.ry}
width={g.width}
x={g.x}
y={g.y}
{...styleAttrs(g)}
/>
)
case 'circle':
return <circle cx={g.cx} cy={g.cy} key={keyHint} r={g.r} {...styleAttrs(g)} />
case 'line':
return <line key={keyHint} x1={g.x1} x2={g.x2} y1={g.y1} y2={g.y2} {...styleAttrs(g)} />
case 'text':
return (
<text
dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={g.fill ?? '#171717'}
fontFamily={g.fontFamily}
fontSize={g.fontSize}
fontWeight={g.fontWeight}
key={keyHint}
opacity={g.opacity}
paintOrder={g.paintOrder}
stroke={g.stroke}
strokeLinecap={g.stroke ? 'round' : undefined}
strokeLinejoin={g.stroke ? 'round' : undefined}
strokeWidth={g.strokeWidth}
textAnchor={g.textAnchor ?? 'start'}
x={g.x}
y={g.y}
>
{g.text}
</text>
)
case 'image':
return (
<FloorplanImage
center={g.center}
height={g.height}
key={keyHint}
opacity={g.opacity}
preserveAspectRatio={g.preserveAspectRatio ?? 'xMidYMid meet'}
rotation={g.rotation ?? 0}
url={g.url}
width={g.width}
/>
)
case 'group': {
const transform = formatTransform(g.transform)
return (
<g key={keyHint} transform={transform}>
{g.children.map((child, i) => renderNode(child, i))}
</g>
)
}
// The interactive primitives (hatch / hit-line / endpoint-handle /
// dimension-label) need the SVG context + theme palette + units-per-
// pixel that only the registry layer has access to. They're rendered
// by `floorplan-registry-layer.tsx`'s interactive walker instead. If
// a caller routes one of these through this pure renderer it
// silently drops — the static renderer is for static output.
default:
return null
}
}
function pointsToAttr(points: readonly (readonly [number, number])[]): string {
return points.map(([x, y]) => `${x},${y}`).join(' ')
}
function formatTransform(t?: {
translate?: readonly [number, number]
rotate?: number
}): string | undefined {
if (!t) return undefined
const parts: string[] = []
if (t.translate) parts.push(`translate(${t.translate[0]} ${t.translate[1]})`)
if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`)
return parts.length > 0 ? parts.join(' ') : undefined
}
/**
* `image` primitive renderer. Resolves the URL asynchronously via
* `loadAssetUrl` (handles CDN / Supabase storage) and renders an SVG
* `<image>` centered at `center`, rotated around it, sized in plan-local
* metres. While the resolution is in flight, renders nothing.
*/
function FloorplanImage({
url,
center,
width,
height,
rotation,
preserveAspectRatio,
opacity,
}: {
url: string
center: readonly [number, number]
width: number
height: number
rotation: number
preserveAspectRatio: string
opacity?: number
}) {
const [resolvedUrl, setResolvedUrl] = useState<string | null>(null)
useEffect(() => {
if (!url) {
setResolvedUrl(null)
return
}
let cancelled = false
setResolvedUrl(null)
loadAssetUrl(url).then((next) => {
if (!cancelled) setResolvedUrl(next)
})
return () => {
cancelled = true
}
}, [url])
if (!resolvedUrl) return null
const rotationDeg = (rotation * 180) / Math.PI
return (
<g
pointerEvents="none"
transform={`translate(${center[0]} ${center[1]}) rotate(${rotationDeg})`}
>
<image
height={height}
href={resolvedUrl}
opacity={opacity}
preserveAspectRatio={preserveAspectRatio}
width={width}
x={-width / 2}
y={-height / 2}
/>
</g>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,113 +0,0 @@
'use client'
import type { Point2D, RoofNode, RoofSegmentNode } from '@pascal-app/core'
import { memo } from 'react'
import { toSvgX, toSvgY } from '../svg-paths'
type FloorplanLineSegment = {
start: Point2D
end: Point2D
}
type FloorplanRoofSegmentEntry = {
segment: RoofSegmentNode
points: string
ridgeLine: FloorplanLineSegment | null
}
type FloorplanRoofEntry = {
roof: RoofNode
segments: FloorplanRoofSegmentEntry[]
}
type FloorplanRoofPalette = {
roofFill: string
roofActiveFill: string
roofSelectedFill: string
roofStroke: string
roofActiveStroke: string
roofSelectedStroke: string
roofRidgeStroke: string
roofSelectedRidgeStroke: string
}
type FloorplanRoofLayerProps = {
highlightedIdSet: ReadonlySet<string>
palette: FloorplanRoofPalette
roofEntries: FloorplanRoofEntry[]
selectedIdSet: ReadonlySet<string>
}
export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
highlightedIdSet,
palette,
roofEntries,
selectedIdSet,
}: FloorplanRoofLayerProps) {
if (roofEntries.length === 0) {
return null
}
return (
<>
{roofEntries.map(({ roof, segments }) => {
const roofSelected = selectedIdSet.has(roof.id)
const roofHighlighted = highlightedIdSet.has(roof.id)
const hasSelectedSegment = segments.some(({ segment }) => selectedIdSet.has(segment.id))
const hasHighlightedSegment = segments.some(({ segment }) =>
highlightedIdSet.has(segment.id),
)
const isRoofActive =
roofSelected || roofHighlighted || hasSelectedSegment || hasHighlightedSegment
return (
<g key={roof.id} pointerEvents="none">
{segments.map(({ points, ridgeLine, segment }) => {
const isSegmentSelected = selectedIdSet.has(segment.id)
const isSegmentHighlighted = highlightedIdSet.has(segment.id)
const isSegmentActive = isSegmentSelected || isSegmentHighlighted
return (
<g key={segment.id}>
<polygon
fill={
isSegmentActive
? palette.roofSelectedFill
: isRoofActive
? palette.roofActiveFill
: palette.roofFill
}
points={points}
stroke={
isSegmentActive
? palette.roofSelectedStroke
: isRoofActive
? palette.roofActiveStroke
: palette.roofStroke
}
strokeWidth={isSegmentActive ? '2.25' : isRoofActive ? '1.75' : '1.1'}
vectorEffect="non-scaling-stroke"
/>
{ridgeLine ? (
<line
fill="none"
stroke={
isSegmentActive ? palette.roofSelectedRidgeStroke : palette.roofRidgeStroke
}
strokeWidth={isSegmentActive ? '2' : '1.4'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(ridgeLine.start.x)}
x2={toSvgX(ridgeLine.end.x)}
y1={toSvgY(ridgeLine.start.y)}
y2={toSvgY(ridgeLine.end.y)}
/>
) : null}
</g>
)
})}
</g>
)
})}
</>
)
})
@@ -273,10 +273,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
fill={curvedAccent}
key={`${stair.id}:spiral-arrow`}
pointerEvents="none"
points={buildSvgArrowHeadPoints(
points={formatSvgPolygonPoints(
buildSvgArrowHeadPoints(
arrowPoint,
tangentAngle,
clamp(stair.width * 0.18, 0.12, 0.18),
),
)}
/>
)
@@ -361,10 +363,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
fill={curvedAccent}
key={`${stair.id}:curved-arrow`}
pointerEvents="none"
points={buildSvgArrowHeadPoints(
points={formatSvgPolygonPoints(
buildSvgArrowHeadPoints(
arrowPoint,
tangentAngle,
clamp(stair.width * 0.16, 0.1, 0.16),
),
)}
/>
)
@@ -103,7 +103,15 @@ export function formatSvgPolygonPoints(points: Point2D[]) {
return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ')
}
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
/**
* Three points defining an arrow head — tip + two trailing barbs.
* Returned as plain `Point2D` objects so consumers can either feed them
* straight into `formatSvgPolygonPoints` (for SVG `points=""`) or push
* them onto a `FloorplanGeometry.polygon.points` array. Mixing both
* downstream paths through a string-returning helper was awkward — see
* `nodes/src/stair/floorplan.ts` which needs the points as objects.
*/
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number): Point2D[] {
const left = {
x: point.x - size * Math.cos(angle - Math.PI / 6),
y: point.y - size * Math.sin(angle - Math.PI / 6),
@@ -113,7 +121,7 @@ export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: num
y: point.y - size * Math.sin(angle + Math.PI / 6),
}
return formatSvgPolygonPoints([point, left, right])
return [point, left, right]
}
export { toSvgPoint, toSvgX, toSvgY }
@@ -7,7 +7,7 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -4,8 +4,8 @@ import '../../three-types'
import {
type AnyNode,
type AnyNodeId,
type ElevatorNode,
type ElevatorDoorSide,
type ElevatorNode,
emitter,
getElevatorCabCenterZ,
getElevatorCabDepth,
@@ -18,10 +18,10 @@ import {
getElevatorShaftWidth,
getResolvedElevatorDoorStyle,
openElevatorDoor,
requestElevatorLevel,
resolveElevatorBuildingLevels,
resolveElevatorDispatchTarget,
resolveElevatorServiceLevels,
requestElevatorLevel,
sceneRegistry,
useInteractive,
useScene,
@@ -352,7 +352,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
const nodes = useScene.getState().nodes
const meshes: ElevatorColliderMesh[] = []
for (const elevatorId of sceneRegistry.byType.elevator) {
for (const elevatorId of sceneRegistry.byType.elevator!) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator' || node.visible === false) continue
@@ -585,7 +585,7 @@ export const FirstPersonControls = () => {
let closestDoorId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const doorId of sceneRegistry.byType.door) {
for (const doorId of sceneRegistry.byType.door!) {
const node = nodes[doorId as AnyNodeId]
if (node?.type !== 'door') continue
if (node.openingKind === 'opening') continue
@@ -683,7 +683,7 @@ export const FirstPersonControls = () => {
let closestWindowId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const windowId of sceneRegistry.byType.window) {
for (const windowId of sceneRegistry.byType.window!) {
const node = nodes[windowId as AnyNodeId]
if (node?.type !== 'window') continue
if (node.openingKind === 'opening') continue
@@ -713,7 +713,7 @@ export const FirstPersonControls = () => {
let closestTarget: FirstPersonInteractableTarget | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const elevatorId of sceneRegistry.byType.elevator) {
for (const elevatorId of sceneRegistry.byType.elevator!) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator') continue
@@ -1088,11 +1088,11 @@ export const FirstPersonControls = () => {
const elevatorIds = activeRide
? [
activeRide.elevatorId,
...Array.from(sceneRegistry.byType.elevator).filter(
...Array.from(sceneRegistry.byType.elevator!).filter(
(elevatorId) => elevatorId !== activeRide.elevatorId,
),
]
: Array.from(sceneRegistry.byType.elevator)
: Array.from(sceneRegistry.byType.elevator!)
for (const elevatorId of elevatorIds) {
const typedElevatorId = elevatorId as AnyNodeId
@@ -176,7 +176,7 @@ function buildRegisteredNodeTypeLookup() {
const nodeTypes = new Map<string, ColliderNodeType>()
for (const type of COLLIDER_NODE_TYPES) {
for (const nodeId of sceneRegistry.byType[type]) {
for (const nodeId of sceneRegistry.byType[type]!) {
nodeTypes.set(nodeId, type)
}
}
@@ -238,7 +238,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
}
for (const type of COLLIDER_NODE_TYPES) {
for (const nodeId of sceneRegistry.byType[type]) {
for (const nodeId of sceneRegistry.byType[type]!) {
if (shouldSkipColliderNode(nodeId, type)) continue
const root = sceneRegistry.nodes.get(nodeId)
@@ -10,6 +10,8 @@ import {
FenceNode,
generateId,
ItemNode,
isRegistrySelectable,
nodeRegistry,
RoofSegmentNode,
type SlabNode,
SpawnNode,
@@ -78,7 +80,12 @@ export function FloatingActionMenu() {
// Subscribe just to the selected node so unrelated scene updates do not
// re-render this menu.
const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
// ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any
// NodeDefinition with `capabilities.selectable`) get the floating menu
// by default too. Phase 4 collapses these into a single registry check.
const isValidType = node
? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type)
: false
// Boolean selector, only re-renders when curving availability actually flips.
const canCurveSelectedWall = useScene((s) => {
@@ -195,7 +202,11 @@ export function FloatingActionMenu() {
node.type === 'roof' ||
node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment'
node.type === 'stair-segment' ||
// Registry-driven kinds default to movable; MoveTool dispatches them
// to MoveRegistryNodeTool. Phase 4 reads `capabilities.movable` to
// gate this instead of the unconditional OR.
isRegistrySelectable(node.type)
) {
setMovingNode(node as any)
}
@@ -289,6 +300,16 @@ export function FloatingActionMenu() {
} else if (node.type === 'spawn') {
duplicate = SpawnNode.parse(duplicateInfo)
}
// Registry-driven fallback: any kind with a NodeDefinition can be
// duplicated through its schema's parse(). Future built-in kinds
// get duplicate for free.
if (!duplicate) {
const def = nodeRegistry.get(node.type)
if (def) {
duplicate = def.schema.parse(duplicateInfo) as AnyNode
}
}
} catch (error) {
console.error('Failed to parse duplicate', error)
useScene.temporal.getState().resume()
@@ -331,6 +352,19 @@ export function FloatingActionMenu() {
}
// Duplicate children for stair nodes
} else if (nodeRegistry.has(duplicate.type)) {
// Registry-driven kinds: offset the position slightly so the
// duplicate doesn't overlap exactly, then create + hand to the
// move tool. Mirrors the roof-segment / stair-segment behavior.
if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
const pos = (duplicate as { position: [number, number, number] }).position
;(duplicate as { position: [number, number, number] }).position = [
pos[0] + 1,
pos[1],
pos[2] + 1,
]
}
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
}
if (
duplicate.type === 'item' ||
@@ -342,7 +376,10 @@ export function FloatingActionMenu() {
duplicate.type === 'door' ||
duplicate.type === 'roof-segment' ||
duplicate.type === 'spawn' ||
duplicate.type === 'stair-segment'
duplicate.type === 'stair-segment' ||
// Registry-driven kinds get picked up by MoveTool's generic
// fallback (MoveRegistryNodeTool) so the user can reposition.
nodeRegistry.has(duplicate.type)
) {
setMovingNode(duplicate as any)
} else if (duplicate.type === 'stair') {
File diff suppressed because it is too large Load Diff
@@ -20,7 +20,6 @@ import {
import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
import { useAutoFrame } from '../../hooks/use-auto-frame'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard'
import {
@@ -7,13 +7,17 @@ import {
emitter,
type FenceNode,
getMaterialPresetByRef,
getSelectableKinds,
type ItemNode,
isRegistrySelectable,
type NodeEvent,
nodeRegistry,
type RoofEvent,
type RoofNode,
type RoofSegmentEvent,
resolveLevelId,
resolveMaterial,
type ShelfNode,
type SlabNode,
type StairEvent,
type StairNode,
@@ -338,7 +342,7 @@ function applyStairPaintPreview(
}
function applySingleSurfacePaintPreview(
node: FenceNode | ColumnNode | SlabNode | CeilingNode,
node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
if (node.type === 'ceiling') {
@@ -406,6 +410,23 @@ function applySingleSurfacePaintPreview(
}
}
if (node.type === 'shelf') {
// Shelf is a registered Group, not a Mesh. Traverse children and
// preview-swap every child mesh — same approach `column` uses.
if (!registeredObject) return null
const restores: PaintPreviewCleanup[] = []
registeredObject.traverse((object) => {
if (!(object as Mesh).isMesh) return
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
if (!mesh) return null
if (node.type === 'slab') {
@@ -648,6 +669,11 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
}
if (node.type === 'window' || node.type === 'door') return true
// Registry-driven: any kind whose NodeDefinition declares the
// `selectable` capability is also selectable in structure phase. Phase 4
// makes this the only path and deletes the hardcoded chain above.
if (isRegistrySelectable(node.type)) return true
return false
},
},
@@ -676,14 +702,43 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
},
isValid: (node) => {
if (!isNodeInCurrentLevel(node)) return false
if (node.type !== 'item') return false
// Item: door/window-category items belong to structure phase, not furnish.
if (node.type === 'item') {
const item = node as ItemNode
return item.asset.category !== 'door' && item.asset.category !== 'window'
}
// Registry-driven kinds with `category: 'furnish'` (shelf today,
// future furniture kinds): selectable in furnish phase if their
// definition declares the `selectable` capability. Without this
// branch, shelf clicks routed to furnish phase via getSelectionTarget
// would be rejected here — single-click selection broken.
const def = nodeRegistry.get(node.type)
if (def && def.category === 'furnish' && def.capabilities.selectable) return true
return false
},
},
}
const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
// Item is checked FIRST so its asset.category-driven routing (door/
// window items land in structure phase, everything else in furnish)
// beats the generic registry fallback below. Without this, registering
// `item` (Phase 5) made isRegistrySelectable('item') match the
// structure branch first, breaking single-click selection: first click
// switched the editor to structure phase, second click selected.
if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') {
return {
phase: 'structure',
structureLayer: 'elements',
}
}
return {
phase: 'furnish',
}
}
if (node.type === 'zone') {
return {
phase: 'structure',
@@ -712,18 +767,16 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
}
}
if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') {
return {
phase: 'structure',
structureLayer: 'elements',
}
}
return {
phase: 'furnish',
// Registry-driven kinds (Phase 5+): route by `def.category`. Built-ins
// above match before this fallback. Furnish-category kinds (shelf,
// item — already handled above) land on the furnish phase; structure-
// category kinds (everything else) on structure/elements.
const def = nodeRegistry.get(node.type)
if (def) {
if (def.category === 'furnish') {
return { phase: 'furnish' }
}
return { phase: 'structure', structureLayer: 'elements' }
}
return null
@@ -899,7 +952,8 @@ export const SelectionManager = () => {
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling'
node.type === 'ceiling' ||
node.type === 'shelf'
) {
const compatible = hasActivePaintMaterial(activePaintMaterial)
@@ -914,7 +968,7 @@ export const SelectionManager = () => {
.updateNode(
node.id as AnyNodeId,
buildSingleSurfaceMaterialPatch<
FenceNode | ColumnNode | SlabNode | CeilingNode
FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
)
}
@@ -922,7 +976,7 @@ export const SelectionManager = () => {
preview: compatible
? () =>
applySingleSurfacePaintPreview(
node as FenceNode | ColumnNode | SlabNode | CeilingNode,
node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
activePaintMaterial,
)
: () => previewCursor('not-allowed'),
@@ -1017,14 +1071,21 @@ export const SelectionManager = () => {
'zone',
] as const
for (const type of allTypes) {
// Registry-driven kinds get the same subscriptions as the hardcoded list,
// so future built-in nodes don't need to edit allTypes per migration.
const registryKinds = getSelectableKinds().filter(
(k) => !(allTypes as readonly string[]).includes(k),
)
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
for (const type of subscribedKinds) {
emitter.on(`${type}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any)
emitter.on(`${type}:click` as any, onClick as any)
}
return () => {
for (const type of allTypes) {
for (const type of subscribedKinds) {
emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any)
emitter.off(`${type}:click` as any, onClick as any)
@@ -1151,7 +1212,10 @@ export const SelectionManager = () => {
}
if (
(node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') &&
(node.type === 'fence' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'shelf') &&
nodeToSelect.type === node.type
) {
setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
@@ -1187,7 +1251,14 @@ export const SelectionManager = () => {
'window',
'door',
]
allTypes.forEach((type) => {
// Registry-driven kinds get the same subscriptions as the hardcoded list,
// so future built-in nodes don't need to edit allTypes per migration.
const registryKinds = getSelectableKinds().filter(
(k) => !(allTypes as readonly string[]).includes(k),
)
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
subscribedKinds.forEach((type) => {
emitter.on(`${type}:click` as any, onClick as any)
})
@@ -1208,7 +1279,7 @@ export const SelectionManager = () => {
emitter.on('grid:click', onGridClick)
return () => {
allTypes.forEach((type) => {
subscribedKinds.forEach((type) => {
emitter.off(`${type}:click` as any, onClick as any)
})
emitter.off('grid:click', onGridClick)
@@ -1340,14 +1411,19 @@ export const SelectionManager = () => {
'zone',
'site',
]
allTypes.forEach((type) => {
const registryKinds = getSelectableKinds().filter(
(k) => !(allTypes as readonly string[]).includes(k),
)
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
subscribedKinds.forEach((type) => {
emitter.on(`${type}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any)
emitter.on(`${type}:double-click` as any, onDoubleClick as any)
})
return () => {
allTypes.forEach((type) => {
subscribedKinds.forEach((type) => {
emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any)
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
@@ -1414,14 +1490,19 @@ export const SelectionManager = () => {
'zone',
] as const
for (const type of allTypes) {
const registryKinds = getSelectableKinds().filter(
(k) => !(allTypes as readonly string[]).includes(k),
)
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
for (const type of subscribedKinds) {
emitter.on(`${type}:click` as any, onClick as any)
emitter.on(`${type}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any)
}
return () => {
for (const type of allTypes) {
for (const type of subscribedKinds) {
emitter.off(`${type}:click` as any, onClick as any)
emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any)
@@ -207,7 +207,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const restoreNodeVisibility = (() => {
const saved = new Map<THREE.Object3D, boolean>()
for (const type of ['scan', 'guide'] as const) {
const ids = sceneRegistry.byType[type]
const ids = sceneRegistry.byType[type]!
ids.forEach((id) => {
const node = sceneRegistry.nodes.get(id)
if (node) {
@@ -179,12 +179,11 @@ export function useFloorplanBackgroundPlacement({
return true
}
if (isFloorplanGridInteractionActive) {
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
setCursorPoint(snappedPoint)
return true
}
// Slab / zone polygon build — local draft state + grid emit.
// Must run BEFORE the `isFloorplanGridInteractionActive` catch-all
// (since slab is registry-driven, the catch-all would otherwise
// swallow the click and skip local draft state updates — leaving
// the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) {
const snappedPoint = snapPolygonDraftPoint({
point: planPoint,
@@ -192,6 +191,13 @@ export function useFloorplanBackgroundPlacement({
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
})
// Emit the grid event so the registry-driven slab tool also
// sees the click (parity with ceiling / fence / roof branches
// above). Zone has no registry tool — emit-or-not is irrelevant.
if (!isZoneBuildActive) {
emitFloorplanGridEvent('click', snappedPoint, event)
}
if (isZoneBuildActive) {
handleZonePlacementPoint(snappedPoint)
} else {
@@ -200,10 +206,12 @@ export function useFloorplanBackgroundPlacement({
return true
}
if (!isWallBuildActive) {
return false
}
// Wall placement — local draft state + grid emit. Same reasoning
// as slab above: wall is registry-driven, so without this branch
// the catch-all would swallow the click and the local draftStart
// / draftEnd state in the floor plan would never update, leaving
// the dashed-line draft preview invisible.
if (isWallBuildActive) {
const snappedPoint = snapWallDraftPoint({
point: planPoint,
walls,
@@ -211,8 +219,21 @@ export function useFloorplanBackgroundPlacement({
angleSnap: Boolean(draftStart) && !shiftPressed,
})
emitFloorplanGridEvent('click', snappedPoint, event)
handleWallPlacementPoint(snappedPoint)
return true
}
// Generic catch-all — registry-driven tool whose kind has no
// local floor-plan draft handler (column / spawn / shelf / etc.).
// The tool's `grid:click` subscriber owns the placement.
if (isFloorplanGridInteractionActive) {
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
setCursorPoint(snappedPoint)
return true
}
return false
},
[
activePolygonDraftPoints,
@@ -46,7 +46,7 @@ export const CeilingSystem = () => {
}
}
const ceilings = sceneRegistry.byType.ceiling
const ceilings = sceneRegistry.byType.ceiling!
ceilings.forEach((ceiling) => {
const mesh = sceneRegistry.nodes.get(ceiling)
if (mesh) {
@@ -8,7 +8,6 @@ import {
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
@@ -1,264 +0,0 @@
'use client'
import {
type AnyNodeId,
type CeilingNode,
emitter,
type GridEvent,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
deltaZ: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
}
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
const originalHolesRef = useRef(
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const previousCursorPosRef = useRef<[number, number, number] | null>(null)
const previousDeltaRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{
polygon: Array<[number, number]>
holes: Array<Array<[number, number]>>
} | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const center = getPolygonCenter(node.polygon)
return [center[0], node.height ?? 2.5, center[1]]
})
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
) => {
previewRef.current = { polygon, holes }
setPreviewPolygon(polygon)
setPreviewHoles(holes)
const center = getPolygonCenter(polygon)
const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
if (
!previousCursorPosRef.current ||
previousCursorPosRef.current[0] !== nextCursorPos[0] ||
previousCursorPosRef.current[1] !== nextCursorPos[1] ||
previousCursorPosRef.current[2] !== nextCursorPos[2]
) {
previousCursorPosRef.current = nextCursorPos
setCursorLocalPos(nextCursorPos)
}
useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId)
}
const restoreOriginal = () => {
setPreviewPolygon(originalPolygon)
setPreviewHoles(originalHoles)
useScene.getState().updateNode(node.id, {
holes: originalHoles,
polygon: originalPolygon,
})
useScene.getState().markDirty(node.id as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
if (
previousDeltaRef.current &&
previousDeltaRef.current[0] === deltaX &&
previousDeltaRef.current[1] === deltaZ
) {
return
}
previousDeltaRef.current = [deltaX, deltaZ]
applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(node.id, {
polygon: originalPolygon,
holes: originalHoles,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [node.id] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node.height, node.id])
const previewFillGeometry = useMemo(
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
[previewHoles, previewPolygon],
)
const previewOutlineGeometry = useMemo(
() => createCeilingOutlineGeometry(previewPolygon),
[previewPolygon],
)
return (
<group>
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
<meshBasicMaterial
color="#f5f5f4"
depthWrite={false}
opacity={0.3}
side={DoubleSide}
transparent
/>
</mesh>
{/* @ts-ignore */}
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
</line>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
function createCeilingPreviewGeometry(
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
): BufferGeometry {
if (polygon.length < 3) return new BufferGeometry()
const shape = new Shape()
const [firstX, firstZ] = polygon[0]!
shape.moveTo(firstX, -firstZ)
for (let i = 1; i < polygon.length; i++) {
const [x, z] = polygon[i]!
shape.lineTo(x, -z)
}
shape.closePath()
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
const hole = new Path()
const [hx, hz] = holePolygon[0]!
hole.moveTo(hx, -hz)
for (let i = 1; i < holePolygon.length; i++) {
const [x, z] = holePolygon[i]!
hole.lineTo(x, -z)
}
hole.closePath()
shape.holes.push(hole)
}
const geometry = new ShapeGeometry(shape)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
return geometry
}
function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
const geometry = new BufferGeometry()
if (polygon.length < 2) return geometry
const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
const [firstX, firstZ] = polygon[0]!
points.push(new Vector3(firstX, 0, firstZ))
geometry.setFromPoints(points)
return geometry
}
@@ -1,425 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import { isWallLongEnough } from '../wall/wall-drafting'
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return (
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
)
}
type SegmentLike = {
id: string
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
type AngleLabelState = {
label: string
position: [number, number, number]
} | null
function getEndpointAngleLabel(args: {
preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number }
segments: SegmentLike[]
nodeId: FenceNode['id']
}): AngleLabelState {
const { preview, segments, nodeId } = args
const endpoints = [
{
point: preview.start,
},
{
point: preview.end,
},
]
const targetSegment: SegmentLike = {
id: nodeId,
start: preview.start,
end: preview.end,
curveOffset: preview.curveOffset,
}
for (const endpoint of endpoints) {
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
if (!targetReference) continue
const connectedSegment = segments.find(
(segment) =>
segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
)
if (!connectedSegment) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
if (angle === null) continue
return {
label: formatAngleRadians(angle),
position: [endpoint.point[0], 0.34, endpoint.point[1]],
}
}
return null
}
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
return [
...walls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...fences.map((fence) => ({
id: fence.id,
start: fence.start,
end: fence.end,
curveOffset: fence.curveOffset,
})),
]
}
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id']
fenceParentId: string | null
linkedPoint: FencePlanPoint
}) {
const { fenceId, fenceParentId, linkedPoint } = args
const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'fence' && node.id !== fenceId)) {
continue
}
if ((node.parentId ?? null) !== fenceParentId) {
continue
}
if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as FencePlanPoint,
end: [...node.end] as FencePlanPoint,
curveOffset: node.curveOffset,
})
}
return snapshots
}
function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[],
linkedPoint: FencePlanPoint,
nextLinkedPoint: FencePlanPoint,
) {
return linkedFences.map((fence) => ({
id: fence.id,
curveOffset: fence.curveOffset,
start: samePoint(fence.start, linkedPoint) ? nextLinkedPoint : fence.start,
end: samePoint(fence.end, linkedPoint) ? nextLinkedPoint : fence.end,
}))
}
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.fence.id)
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
const originalMovingPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.start] as FencePlanPoint)
: ([...target.fence.end] as FencePlanPoint),
)
const fixedPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.end] as FencePlanPoint)
: ([...target.fence.start] as FencePlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({
fenceId: target.fence.id,
fenceParentId: target.fence.parentId ?? null,
linkedPoint: target.endpoint === 'start' ? target.fence.start : target.fence.end,
}),
)
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
return [point[0], 0, point[1]]
})
const [altPressed, setAltPressed] = useState(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingFenceEndpoint(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const originalMovingPoint = originalMovingPointRef.current
const fixedPoint = fixedPointRef.current
const siblings = Object.values(useScene.getState().nodes)
const levelWalls = siblings.filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
const levelFences = siblings.filter(
(node): node is FenceNode =>
node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedFences
? []
: getLinkedFenceUpdates(linkedOriginalsRef.current, originalMovingPoint, movingPoint)
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
setAngleLabel(
getEndpointAngleLabel({
preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
nodeId,
}),
)
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
}
const restoreOriginal = (clearAngleLabel = true) => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
if (clearAngleLabel) {
setAngleLabel(null)
}
}
const onGridMove = (event: GridEvent) => {
const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const snappedPoint = snapFenceDraftPoint({
point: planPoint,
walls: levelWalls,
fences: levelFences,
start: fixedPoint,
angleSnap: !shiftPressedRef.current,
ignoreFenceIds: [nodeId],
})
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
applyPreview(snappedPoint, event.nativeEvent.altKey)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged = !(
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
resumeSceneHistory(useScene)
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalMovingPoint,
target.endpoint === 'start' ? preview.start : preview.end,
)),
])
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
setAngleLabel(null)
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
setAngleLabel(null)
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
if (event.key === 'Alt') {
altPressedRef.current = true
setAltPressed(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
if (event.key === 'Alt') {
altPressedRef.current = false
setAltPressed(false)
}
}
const onWindowBlur = () => {
shiftPressedRef.current = false
altPressedRef.current = false
setAltPressed(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
return () => {
if (!wasCommitted) {
restoreOriginal(false)
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
}
}, [exitMoveMode, target])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
: 'border-border/70 bg-background/90 text-foreground/80'
}`}
>
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
</div>
</div>
</Html>
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
</group>
)
}
function EndpointAngleLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -1,32 +0,0 @@
import type { AssetInput } from '@pascal-app/core'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) {
const draftNode = useDraftNode()
const cursor = usePlacementCoordinator({
asset: selectedItem,
draftNode,
initDraft: (gridPosition) => {
if (selectedItem && !selectedItem.attachTo) {
draftNode.create(gridPosition, selectedItem)
}
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
return true
},
})
return <>{cursor}</>
}
export const ItemTool: React.FC = () => {
const selectedItem = useEditor((state) => state.selectedItem)
if (!selectedItem) return null
return <ItemPlacementContent selectedItem={selectedItem} />
}
@@ -1,121 +1,66 @@
import type {
AnyNodeId,
BuildingNode,
CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode,
FenceNode,
ItemNode,
RoofNode,
RoofSegmentNode,
SlabNode,
SpawnNode,
StairNode,
StairSegmentNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { nodeRegistry } from '@pascal-app/core'
import { Suspense } from 'react'
import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
import { MoveColumnTool } from '../column/move-column-tool'
import { MoveDoorTool } from '../door/move-door-tool'
import { MoveElevatorTool } from '../elevator/move-elevator-tool'
import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveSlabTool } from '../slab/move-slab-tool'
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
import { MoveWallTool } from '../wall/move-wall-tool'
import { MoveWindowTool } from '../window/move-window-tool'
import type { PlacementState } from './placement-types'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
function getInitialState(node: {
asset: { attachTo?: string }
parentId: string | null
}): PlacementState {
const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null }
}
if (attachTo === 'ceiling') {
return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null }
}
return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
}
function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
const draftNode = useDraftNode()
const meta =
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
? (movingNode.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const cursor = usePlacementCoordinator({
asset: movingNode.asset,
draftNode,
// Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
initialState: isNew
? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
: getInitialState(movingNode),
// Preserve the original item's scale so Y-position calculations use the correct height
defaultScale: isNew ? movingNode.scale : undefined,
initDraft: (gridPosition) => {
if (isNew) {
// Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.
// Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.
gridPosition.copy(new Vector3(...movingNode.position))
if (!movingNode.asset.attachTo) {
draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale)
}
} else {
draftNode.adopt(movingNode)
gridPosition.copy(new Vector3(...movingNode.position))
}
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
useEditor.getState().setMovingNode(null)
return false
},
onCancel: () => {
draftNode.destroy()
useEditor.getState().setMovingNode(null)
},
})
return <>{cursor}</>
}
import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
/**
* MoveTool dispatcher. Routes to (in order):
*
* 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
* declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
* …).
* 2. `def.affordanceTools.move` — kind-owned move component
* (slab / ceiling / wall / fence / column / item / door / window).
* Lazy-loaded via `getRegistryAffordanceTool`.
* 3. The narrow set of kinds that still have legacy movers because no
* registry equivalent has been written yet (building / elevator /
* roof / stair). Each of these has bespoke move semantics that
* don't fit the generic mover and are not yet ported to a
* kind-owned affordance.
*/
export const MoveTool: React.FC<{
onNodeMoved?: (nodeId: AnyNodeId) => void
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onNodeMoved, onSpawnMoved }) => {
}> = ({ onNodeMoved }) => {
const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null
const def = nodeRegistry.get(movingNode.type)
if (def?.capabilities?.movable) {
return <MoveRegistryNodeTool node={movingNode} />
}
const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
if (RegistryMove) {
return (
<Suspense fallback={null}>
<RegistryMove node={movingNode} />
</Suspense>
)
}
if (movingNode.type === 'building')
return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'elevator')
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'spawn')
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} />
return null
}
@@ -6,12 +6,15 @@ import type {
GridEvent,
ItemEvent,
ItemNode,
ShelfEvent,
ShelfNode,
WallEvent,
WallNode,
} from '@pascal-app/core'
import {
getScaledDimensions,
isLowProfileItemSurface,
nodeRegistry,
sceneRegistry,
useScene,
} from '@pascal-app/core'
@@ -587,6 +590,156 @@ export const itemSurfaceStrategy = {
},
}
// ============================================================================
// SHELF SURFACE STRATEGY
// ============================================================================
/**
* Resolve the row Y closest to the cursor's local Y. Reads candidate row
* positions from the kind's `capabilities.surfaces.custom` — the shelf
* declaration emits one `SurfacePoint` per board's top surface. The
* strategy stays kind-agnostic at this level: any future "multi-board"
* kind that declares `surfaces.custom` with upward normals gets the
* same hit behaviour for free.
*/
function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
const def = nodeRegistry.get('shelf')
const custom = def?.capabilities?.surfaces?.custom
if (!custom) return null
const candidates = custom(shelfNode as AnyNode)
if (candidates.length === 0) return null
let best = candidates[0]
let bestDist = Math.abs(best!.position[1] - localY)
for (let i = 1; i < candidates.length; i++) {
const c = candidates[i]
if (!c) continue
const dist = Math.abs(c.position[1] - localY)
if (dist < bestDist) {
best = c
bestDist = dist
}
}
return best?.position[1] ?? null
}
export const shelfSurfaceStrategy = {
/**
* Handle shelf:enter — transition the draft onto the closest shelf
* row. Mirrors `itemSurfaceStrategy.enter` but reads candidate
* surface heights from the shelf kind's `surfaces.custom` (one Y per
* board) instead of `asset.surface.height`. Picks the row whose
* surface Y is nearest the cursor's local Y so the user can target a
* specific row by hovering near it.
*/
enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null {
if (ctx.asset.attachTo) return null
const shelfNode = event.node as ShelfNode
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
return null
}
if (!isUpwardShelfSurfaceHit(event)) return null
// Size check: draft footprint must fit on the shelf board (width × depth).
const ourDims = ctx.draftItem
? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
if (!shelfMesh) return null
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = shelfMesh.worldToLocal(worldPos)
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
const surfaceQuat = new Quaternion()
shelfMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
return {
stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
nodeUpdate: {
position: [x, rowY, z],
parentId: shelfNode.id,
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
},
cursorRotationY: ctx.currentCursorRotationY,
gridPosition: [x, rowY, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
stopPropagation: true,
}
},
/**
* Handle shelf:move — re-derive the closest row each tick so the user
* can slide between rows without leaving the shelf.
*/
move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null {
if (ctx.state.surface !== 'shelf-surface') return null
if (!(ctx.state.shelfId && ctx.draftItem)) return null
if (event.node.id !== ctx.state.shelfId) return null
const shelfNode = event.node as ShelfNode
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
if (!shelfMesh) return null
const ourDims = getScaledDimensions(ctx.draftItem)
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = shelfMesh.worldToLocal(worldPos)
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
return {
gridPosition: [x, rowY, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
cursorRotationY: ctx.currentCursorRotationY,
nodeUpdate: { position: [x, rowY, z] },
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle shelf:click — commit placement on the active row.
*/
click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null {
if (ctx.state.surface !== 'shelf-surface') return null
if (!(ctx.draftItem && ctx.state.shelfId)) return null
if (event.node.id !== ctx.state.shelfId) return null
return {
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.state.shelfId,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: null,
}
},
}
/** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
* for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
* via a tiny `ItemEvent`-shaped adapter — the function only reads
* `event.normal` + `event.object`. */
function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
}
// ============================================================================
// VALIDATION
// ============================================================================
@@ -603,6 +756,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
return ctx.state.surfaceItemId !== null
}
// Shelf surface: same — size check already happened on enter
if (ctx.state.surface === 'shelf-surface') {
return ctx.state.shelfId !== null
}
const attachTo = ctx.draftItem.asset.attachTo
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
@@ -12,7 +12,7 @@ import type { Vector3 } from 'three'
// PLACEMENT STATE
// ============================================================================
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface'
/**
* Tracks which surface the draft item is currently on.
@@ -23,6 +23,13 @@ export interface PlacementState {
wallId: string | null
ceilingId: string | null
surfaceItemId: string | null
/**
* Active shelf when `surface === 'shelf-surface'`. Items host on the
* shelf board closest to the cursor's local Y; the row index isn't
* stored separately because every move re-derives it from cursor
* position via `shelfRowSurfaceYs`.
*/
shelfId: string | null
}
// ============================================================================
@@ -179,9 +179,28 @@ export function useDraftNode(): DraftNodeHandle {
if (!draftRef.current) return
if (adoptedRef.current && originalStateRef.current) {
// Move mode: restore original state instead of deleting
// Move mode: restore original state instead of deleting — but only
// if no other system has already committed a new position for this
// node. The 2D `FloorplanRegistryMoveOverlay` commits via
// `useScene.updateNodes` before unmounting the legacy mover, and
// an unconditional restore here would wipe that commit. By
// comparing the live state to the snapshot we took in `adopt()`,
// we let an external committer's write stick.
const original = originalStateRef.current
const id = draftRef.current.id
const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined
const livePosition = live?.position
const externallyMoved =
!!livePosition &&
(livePosition[0] !== original.position[0] ||
livePosition[1] !== original.position[1] ||
livePosition[2] !== original.position[2])
if (externallyMoved) {
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
return
}
useScene.getState().updateNode(id, {
position: original.position,
@@ -7,6 +7,7 @@ import {
getScaledDimensions,
type ItemEvent,
resolveLevelId,
type ShelfEvent,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
@@ -41,6 +42,7 @@ import {
checkCanPlace,
floorStrategy,
itemSurfaceStrategy,
shelfSurfaceStrategy,
wallStrategy,
} from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types'
@@ -286,10 +288,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const gridPosition = useRef(new Vector3(0, 0, 0))
const lastRawPos = useRef(new Vector3(0, 0, 0))
const placementState = useRef<PlacementState>(
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
config.initialState ?? {
surface: 'floor',
wallId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
},
)
const shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null)
// Goes true the first time a 3D pointer event drives this coordinator.
// The per-frame mesh-position lerp below is only useful for that path;
// when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
// writing scene.position directly), the lerp fights React's render and
// pulls the rendered item back toward its pre-move position. Gating
// the lerp on this flag keeps 3D placement smooth without hijacking
// 2D drags that share the same draft.
const has3DPointerDrivenMoveRef = useRef(false)
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
// Store config callbacks in refs to avoid re-running effect when they change
@@ -435,6 +451,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
wallId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
}
if (!asset.attachTo && placementState.current.surface === 'floor') {
gridPosition.current.y = 0
@@ -552,6 +569,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
configRef.current.initDraft(gridPosition.current)
}
has3DPointerDrivenMoveRef.current = true
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
const result = floorStrategy.move(getContext(), event)
if (!result) return
@@ -609,6 +627,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Wall Handlers ----
const onWallEnter = (event: WallEvent) => {
has3DPointerDrivenMoveRef.current = true
const nodes = useScene.getState().nodes
const result = wallStrategy.enter(
getContext(),
@@ -634,6 +653,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onWallMove = (event: WallEvent) => {
has3DPointerDrivenMoveRef.current = true
const ctx = getContext()
if (ctx.state.surface !== 'wall') {
@@ -824,6 +844,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const onItemEnter = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
has3DPointerDrivenMoveRef.current = true
const result = itemSurfaceStrategy.enter(getContext(), event)
if (!result) return
@@ -840,6 +861,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const onItemMove = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
has3DPointerDrivenMoveRef.current = true
const ctx = getContext()
if (ctx.state.surface !== 'item-surface') {
@@ -923,7 +945,102 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onItemClick = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
// Click on the draft item itself. R3F dispatches click events to
// the closest intersected mesh only — when the draft is hovering
// on a host (shelf / table / etc.) the draft's mesh is *above*
// the host's mesh, so the host's `${kind}:click` never fires.
// If we're currently hosting on a shelf-surface, treat the
// self-click as a commit on the active shelf so the user doesn't
// have to aim around the cursor preview to drop the item.
if (event.node.id === draftNode.current?.id) {
const ctx = getContext()
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
if (shelfNode && shelfNode.type === 'shelf') {
const synthetic = { ...event, node: shelfNode } as unknown as ItemEvent
const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
if (result) {
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
return
}
}
}
// Same self-click forwarding for item-surface hosts (tables,
// counters) — the draft mesh sits on top of the host mesh, so
// the host's own click event is blocked by the cursor preview.
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
if (hostNode && hostNode.type === 'item') {
const synthetic = { ...event, node: hostNode } as ItemEvent
const result = itemSurfaceStrategy.click(ctx, synthetic)
if (result) {
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
return
}
}
}
// Ceiling-hosted draft: when placing a ceiling-attached item the
// draft hangs below the ceiling and intercepts the click ray
// before the ceiling-grid mesh does — so `ceiling:click` never
// fires and the user's commit click is dropped. Forward the
// self-click to `ceilingStrategy.click` so placement commits the
// same way it would from a click on the ceiling itself.
if (ctx.state.surface === 'ceiling' && ctx.state.ceilingId) {
const ceilingNode = useScene.getState().nodes[ctx.state.ceilingId as AnyNodeId]
if (ceilingNode && ceilingNode.type === 'ceiling') {
const synthetic = { ...event, node: ceilingNode } as unknown as CeilingEvent
const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators())
if (result) {
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const nodes = useScene.getState().nodes
const enterResult = ceilingStrategy.enter(
getContext(),
synthetic,
resolveLevelId,
nodes,
)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
return
}
}
}
return
}
const result = itemSurfaceStrategy.click(getContext(), event)
if (!result) return
@@ -948,6 +1065,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Ceiling Handlers ----
const onCeilingEnter = (event: CeilingEvent) => {
has3DPointerDrivenMoveRef.current = true
const nodes = useScene.getState().nodes
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!result) return
@@ -967,6 +1085,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onCeilingMove = (event: CeilingEvent) => {
has3DPointerDrivenMoveRef.current = true
if (!draftNode.current && placementState.current.surface === 'ceiling') {
const nodes = useScene.getState().nodes
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
@@ -1065,6 +1184,100 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
}
// ---- Shelf Handlers ----
//
// Items can host on shelves the same way they host on tables and
// counters (item-surface). The shelf's `surfaces.custom` exposes one
// candidate Y per row; `shelfSurfaceStrategy` picks the closest one
// to the cursor's local-Y so the user can target a specific row.
const onShelfEnter = (event: ShelfEvent) => {
has3DPointerDrivenMoveRef.current = true
const result = shelfSurfaceStrategy.enter(getContext(), event)
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
}
}
const onShelfMove = (event: ShelfEvent) => {
has3DPointerDrivenMoveRef.current = true
const ctx = getContext()
if (ctx.state.surface !== 'shelf-surface') {
// Cursor entered via a move event without an enter — try
// transitioning in so the user doesn't need to mouse out + back
// in to start hosting.
const enterResult = shelfSurfaceStrategy.enter(ctx, event)
if (!enterResult) return
event.stopPropagation()
applyTransition(enterResult)
if (!draftNode.current) {
ensureDraft(enterResult)
} else if (enterResult.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
}
return
}
const result = shelfSurfaceStrategy.move(ctx, event)
if (!result) return
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
const ic = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.set(...result.gridPosition)
useLiveTransforms.getState().set(draft.id, {
position: result.cursorPosition,
rotation: result.cursorRotationY,
})
}
revalidate()
}
const onShelfLeave = (event: ShelfEvent) => {
if (placementState.current.surface !== 'shelf-surface') return
if (event.node.id !== placementState.current.shelfId) return
event.stopPropagation()
// Drop back to floor — same pattern as item-leave but without the
// detachItemSurfaceToFloor (no scaled rotation hand-off to deal
// with since the shelf rotation already composed cleanly).
Object.assign(placementState.current, { surface: 'floor', shelfId: null })
}
const onShelfClick = (event: ShelfEvent) => {
const result = shelfSurfaceStrategy.click(getContext(), event)
if (!result) return
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
// ---- Keyboard rotation ----
const ROTATION_STEP = Math.PI / 2
@@ -1239,6 +1452,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('ceiling:move', onCeilingMove)
emitter.on('ceiling:click', onCeilingClick)
emitter.on('ceiling:leave', onCeilingLeave)
emitter.on('shelf:enter', onShelfEnter)
emitter.on('shelf:move', onShelfMove)
emitter.on('shelf:click', onShelfClick)
emitter.on('shelf:leave', onShelfLeave)
return () => {
tearingDown = true
@@ -1263,6 +1480,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('ceiling:move', onCeilingMove)
emitter.off('ceiling:click', onCeilingClick)
emitter.off('ceiling:leave', onCeilingLeave)
emitter.off('shelf:enter', onShelfEnter)
emitter.off('shelf:move', onShelfMove)
emitter.off('shelf:click', onShelfClick)
emitter.off('shelf:leave', onShelfLeave)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
@@ -1297,6 +1518,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useFrame((_, delta) => {
if (!asset) return
if (!draftNode.current) return
// The mesh-position lerp below only makes sense once this coordinator
// owns the move via a 3D pointer event. Skip until then so that
// external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay`
// writing scene.position directly) aren't fought by useFrame pulling
// the mesh back to its pre-move location.
if (!has3DPointerDrivenMoveRef.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return
@@ -0,0 +1,279 @@
'use client'
import '../../../three-types'
import {
type AnyNode,
type AnyNodeId,
type EventSuffix,
emitter,
type GridEvent,
type NodeEvent,
nodeRegistry,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const roundToHalf = (value: number) => Math.round(value * 2) / 2
/**
* Generic move tool for any registry-backed kind.
*
* Imperative-only motion during drag:
* - On every `grid:move` we mutate `sceneRegistry.nodes.get(id).position`
* directly. The node's store data is unchanged → the renderer doesn't
* re-render → R3F doesn't reapply `position={node.position}` → the
* imperative mutation sticks. Movement is smooth, framerate-locked,
* and React-free.
*
* Store update happens only on commit (single undoable action).
*
* Cancel imperatively snaps the mesh back to its original position and
* resumes history without ever having touched the store mid-drag.
*
* **Commit triggers**: the tool listens for `grid:click` *and* the
* common node click events (shelf / item / slab / ceiling / wall /
* fence / column / roof / stair). A click on the grid plane fires
* `grid:click`; a click on the moved node itself (or any other 3D
* geometry the ray happens to land on) fires the corresponding node
* click event. Without the node-click listeners, clicking on the
* cursor's own mesh during a move would silently drop the commit —
* the user perceives "click did nothing" because the click hit the
* vertical face of e.g. a shelf instead of the grid plane below it.
*
* The latest cursor position from `grid:move` is stored in a ref so
* any of these click variants commit at the same spot the cursor was
* indicating.
*/
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
const CLICK_TRIGGER_KINDS = [
'shelf',
'item',
'slab',
'ceiling',
'wall',
'fence',
'column',
'roof',
'roof-segment',
'stair',
'stair-segment',
] as const
export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const originalPosition: [number, number, number] = useMemo(
() =>
'position' in node && Array.isArray((node as { position?: unknown }).position)
? ((node as { position: [number, number, number] }).position ?? [0, 0, 0])
: [0, 0, 0],
[node],
)
/**
* Y-axis rotation of the node at move-start. Captured so the
* imperative drag preview (and the `useLiveTransforms` mirror) keeps
* the original orientation — otherwise hardcoding `rotation: 0` in
* `useLiveTransforms.set` would override `node.rotation[1]` during
* the drag, the shelf would visually un-rotate to 0, then snap back
* to its true rotation on commit (when the live transform clears).
* The user reads that snap as "reverts to a weird position".
*/
const originalRotationY: number = useMemo(() => {
if ('rotation' in node) {
const r = (node as { rotation?: unknown }).rotation
if (typeof r === 'number') return r
if (Array.isArray(r)) return (r as [number, number, number])[1] ?? 0
}
return 0
}, [node])
const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
const previousSnapRef = useRef<[number, number] | null>(null)
/**
* The latest snapped cursor position from `grid:move`. We commit at
* THIS position regardless of which event variant fires the click —
* a `grid:click` carries the same coords, but a node-click (e.g.
* `shelf:click`) carries the hit point on the clicked node's mesh,
* which can be slightly off-cursor when the user clicks the vertical
* face of the moved node itself. Reading from the ref keeps the
* commit position consistent with the visible cursor.
*/
const lastCursorRef = useRef<[number, number, number]>(originalPosition)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
previousSnapRef.current = null
let committed = false
// Disable raycast on the moved node's meshes for the duration of
// the drag. As the shelf follows the cursor, the cursor ray would
// otherwise hit the moved mesh first → only `${kind}:move` fires →
// `grid:move` stops updating `lastCursorRef` → clicks would commit
// at the stale (initial) position. With raycast disabled, the ray
// passes through the moved mesh and continues to the grid plane,
// so `grid:move` keeps firing and the cursor tracks correctly.
// We restore the original raycast on cleanup.
const mesh = sceneRegistry.nodes.get(node.id)
const restoreRaycasts: Array<() => void> = []
if (mesh) {
mesh.traverse((child) => {
const original = child.raycast
child.raycast = () => {}
restoreRaycasts.push(() => {
child.raycast = original
})
})
}
const onGridMove = (event: GridEvent) => {
const x = roundToHalf(event.localPosition[0])
const z = roundToHalf(event.localPosition[2])
setCursorPosition([x, 0, z])
lastCursorRef.current = [x, 0, z]
// Pure imperative: move the mesh via its registered Object3D ref.
sceneRegistry.nodes.get(node.id)?.position.set(x, 0, z)
// Publish to `useLiveTransforms` so the 2D floor plan can mirror
// the drag in real-time (the floor-plan layer subscribes to this
// store and overrides the node's rendered position when an entry
// is set). Without this the 2D representation stays at the
// committed scene position until the move ends.
//
// For position-based kinds (shelf, item, column, spawn) we write
// the absolute world plan position here. Polygon-based kinds
// (slab / ceiling / fence) follow a different delta contract —
// their floor-plan move-targets handle the override themselves.
useLiveTransforms.getState().set(node.id, {
position: [x, 0, z],
rotation: originalRotationY,
})
const prev = previousSnapRef.current
if (!prev || prev[0] !== x || prev[1] !== z) {
sfxEmitter.emit('sfx:grid-snap')
previousSnapRef.current = [x, z]
}
}
/** Commit the move at the latest cursor position. Shared by every
* click variant — grid plane, the moved node itself, or any other
* 3D surface the user happens to click on during the move.
*
* Order is deliberate: write scene FIRST, then clear
* `useLiveTransforms`. If we cleared the live transform first,
* `ParametricNodeRenderer` would re-render with
* `position = liveTransform?.position ?? node.position` → undefined
* → original `node.position` (the scene write hasn't happened yet),
* briefly snapping the mesh back to its starting spot before the
* next render lands the new position. Writing scene first means
* every render shows either the live drag position (liveTransform
* still set) or the new committed position (liveTransform cleared
* AND scene updated) — never the original.
*/
const commitAtCursor = (event: ClickTriggerEvent) => {
const position: [number, number, number] = [...lastCursorRef.current]
if (useScene.getState().nodes[node.id]) {
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, { position } as Partial<AnyNode>)
useScene.temporal.getState().pause()
committed = true
} else if (node.parentId) {
// Orphan re-create path: re-parse via the registry's schema.
const def = nodeRegistry.get(node.type)
if (def) {
const reparsed = def.schema.parse({
...(node as Record<string, unknown>),
id: undefined,
metadata: {},
position,
})
useScene.temporal.getState().resume()
useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId)
useScene.temporal.getState().pause()
committed = true
}
}
// Keep mesh.position aligned with the just-committed scene position
// so the next R3F frame paints at the right spot even if React's
// reconciliation lags by a tick.
const mesh = sceneRegistry.nodes.get(node.id)
if (mesh) mesh.position.set(position[0], position[1], position[2])
// Now safe to clear — node.position is already the new value, so
// `ParametricNodeRenderer`'s next render lands at `[x, 0, z]`.
useLiveTransforms.getState().clear(node.id)
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
// Stop further propagation so other listeners (e.g. a selection
// change on the clicked node) don't fire during the commit click.
const native = (event as { nativeEvent?: unknown }).nativeEvent
if (
native &&
typeof (native as { stopPropagation?: () => void }).stopPropagation === 'function'
) {
;(native as { stopPropagation: () => void }).stopPropagation()
}
const direct = (event as { stopPropagation?: () => void }).stopPropagation
if (typeof direct === 'function') direct.call(event)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', commitAtCursor)
// Listen on every common kind's click event too. mitt's typing keeps
// `${kind}:click` as a fixed union so the cast is safe at runtime —
// we're just routing them through the shared commit path.
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.on(key, commitAtCursor as never)
}
const onCancel = () => {
sceneRegistry.nodes
.get(node.id)
?.position.set(originalPosition[0], originalPosition[1], originalPosition[2])
useLiveTransforms.getState().clear(node.id)
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', commitAtCursor)
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.off(key, commitAtCursor as never)
}
emitter.off('tool:cancel', onCancel)
// Restore the moved meshes' raycast so they're hoverable / selectable
// again after the drag ends.
for (const restore of restoreRaycasts) restore()
if (!committed) {
sceneRegistry.nodes
.get(node.id)
?.position.set(originalPosition[0], originalPosition[1], originalPosition[2])
useLiveTransforms.getState().clear(node.id)
useScene.temporal.getState().resume()
}
}
}, [exitMoveMode, node, originalPosition, originalRotationY])
return <CursorSphere color="#a78bfa" height={2.5} position={cursorPosition} />
}
@@ -0,0 +1,30 @@
import { nodeRegistry } from '@pascal-app/core'
import { type ComponentType, lazy } from 'react'
/**
* Phase 5 Stage D — runtime lazy-load of a kind's affordance tool.
*
* The editor can't statically import from `@pascal-app/nodes` (the
* nodes package depends on editor — static imports would cycle). The
* kind declares its drag-affordance components in
* `def.affordanceTools[<key>]: () => import('./<name>-tool')`; this
* helper resolves that to a `React.lazy` component at the call site.
*
* Returns null when the kind doesn't declare the affordance — callers
* mount the legacy fallback in that case.
*/
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
export function getRegistryAffordanceTool(
kind: string,
affordance: string,
): ComponentType<any> | null {
const def = nodeRegistry.get(kind)
const loader = def?.affordanceTools?.[affordance]
if (!loader) return null
const cached = lazyToolCache.get(loader)
if (cached) return cached
const Comp = lazy(loader as () => Promise<{ default: ComponentType<any> }>)
lazyToolCache.set(loader, Comp as unknown as ComponentType)
return Comp as unknown as ComponentType<any>
}
@@ -1,182 +0,0 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
type SlabNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere'
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
deltaZ: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
}
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
const originalHolesRef = useRef(
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{
polygon: Array<[number, number]>
holes: Array<Array<[number, number]>>
} | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const center = getPolygonCenter(node.polygon)
return [center[0], 0, center[1]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is WallNode => child?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
) => {
previewRef.current = { polygon, holes }
const center = getPolygonCenter(polygon)
setCursorLocalPos([center[0], 0, center[1]])
useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId)
}
const restoreOriginal = () => {
useScene.getState().updateNode(node.id, {
holes: originalHoles,
polygon: originalPolygon,
})
useScene.getState().markDirty(node.id as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(node.id, {
polygon: originalPolygon,
holes: originalHoles,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [node.id] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node.id])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,101 +0,0 @@
import '../../../three-types'
import {
emitter,
type GridEvent,
type SpawnNode,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useCallback, useEffect, useState } from 'react'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const worldVector = new Vector3()
function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] {
const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null
if (!levelObject) {
return [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
}
export const MoveSpawnTool: React.FC<{
node: SpawnNode
onCommitted?: (nodeId: SpawnNode['id']) => void
}> = ({ node, onCommitted }) => {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
let committed = false
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
setPreviewPosition(nextPosition)
useLiveTransforms.getState().set(node.id, {
position: [...nextPosition],
rotation: node.rotation,
})
}
const onGridClick = (event: GridEvent) => {
const nextPosition = getLevelLocalSpawnPosition(node, event)
committed = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, { position: nextPosition })
onCommitted?.(node.id)
useLiveTransforms.getState().clear(node.id)
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
}
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
useScene.temporal.getState().resume()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id)
if (!committed) {
useScene.temporal.getState().resume()
}
}
}, [exitMoveMode, node, onCommitted])
return (
<CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
)
}
@@ -1,130 +0,0 @@
import '../../../three-types'
import {
emitter,
type GridEvent,
type LevelNode,
SpawnNode,
type SpawnNode as SpawnNodeType,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const SPAWN_ICON = (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Spawn Point"
src="/icons/site.png"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
)
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const worldVector = new Vector3()
function getExistingSpawnIds() {
const nodes = useScene.getState().nodes
return Object.values(nodes)
.filter((node) => node.type === 'spawn')
.map((node) => node.id)
.sort()
}
function getLevelLocalSpawnPosition(
levelId: LevelNode['id'],
event: GridEvent,
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
return [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
}
type SpawnToolProps = {
currentLevelId: LevelNode['id'] | null
onPlaced?: (spawnId: SpawnNodeType['id']) => void
}
export const SpawnTool: React.FC<SpawnToolProps> = ({ currentLevelId, onPlaced }) => {
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
const cursorRef = useRef<Group>(null)
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
setCursorPosition(nextPosition)
cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2])
}
const onGridClick = (event: GridEvent) => {
const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event)
const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds()
if (existingSpawnId) {
useScene.getState().updateNode(existingSpawnId, {
parentId: currentLevelId,
position: nextPosition,
rotation: 0,
})
if (duplicateSpawnIds.length > 0) {
useScene.getState().deleteNodes(duplicateSpawnIds)
}
onPlaced?.(existingSpawnId)
} else {
const spawn = SpawnNode.parse({
name: 'Spawn Point',
position: nextPosition,
rotation: 0,
})
useScene.getState().createNode(spawn, currentLevelId)
onPlaced?.(spawn.id)
}
sfxEmitter.emit('sfx:structure-build')
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
}
}, [currentLevelId, onPlaced])
if (!currentLevelId) return null
return (
<CursorSphere
color="#60a5fa"
height={2.2}
ref={cursorRef}
showTooltip
tooltipContent={SPAWN_ICON}
/>
)
}
@@ -1,5 +1,4 @@
import {
type AnyNode,
emitter,
type GridEvent,
type LevelNode,
@@ -2,55 +2,51 @@ import {
type AnyNodeId,
type BuildingNode,
type CeilingNode,
nodeRegistry,
type SlabNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { ColumnTool } from './column/column-tool'
import { DoorTool } from './door/door-tool'
import { ElevatorTool } from './elevator/elevator-tool'
import { CurveFenceTool } from './fence/curve-fence-tool'
import { FenceTool } from './fence/fence-tool'
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool'
import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool'
import { SpawnTool } from './spawn/spawn-tool'
import { StairTool } from './stair/stair-tool'
import { CurveWallTool } from './wall/curve-wall-tool'
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
import { ZoneTool } from './zone/zone-tool'
// Cache lazy tool components keyed by their loader so React.lazy isn't
// re-invoked across renders.
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
function getRegistryTool(tool: Tool | null): ComponentType | null {
if (!tool) return null
const def = nodeRegistry.get(tool)
if (!def?.tool) return null
const cached = lazyToolCache.get(def.tool)
if (cached) return cached
const Comp = lazy(def.tool as () => Promise<{ default: ComponentType }>)
lazyToolCache.set(def.tool, Comp)
return Comp
}
// Legacy tool fallbacks — kinds whose placement tools haven't migrated
// to `def.tool` yet. Wall / fence / slab / ceiling / door / window /
// item / shelf / spawn now go through the registry path above.
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: {
'property-line': SiteBoundaryEditor,
},
structure: {
wall: WallTool,
fence: FenceTool,
slab: SlabTool,
ceiling: CeilingTool,
roof: RoofTool,
stair: StairTool,
door: DoorTool,
item: ItemTool,
zone: ZoneTool,
window: WindowTool,
},
furnish: {
item: ItemTool,
},
furnish: {},
}
export const ToolManager: React.FC = () => {
@@ -127,7 +123,12 @@ export const ToolManager: React.FC = () => {
// Show build tools when in build mode
const showBuildTool = mode === 'build' && tool !== null
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
// Registry-first: if the active tool's kind has a NodeDefinition with a
// tool contribution, the registry-driven tool takes over.
const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
const useRegistryTool = RegistryToolComponent != null
const BuildToolComponent = showBuildTool && !useRegistryTool ? tools[phase]?.[tool] : null
const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
setSelection({ selectedIds: [nodeId] })
}
@@ -154,44 +155,114 @@ export const ToolManager: React.FC = () => {
rotation={buildingRotation as [number, number, number]}
>
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
{showSlabHoleEditor && selectedSlabId && editingHole && (
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
)}
{showCeilingBoundaryEditor && selectedCeilingId && (
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
)}
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)}
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />}
{curvingFence && <CurveFenceTool node={curvingFence} />}
{showSlabBoundaryEditor &&
selectedSlabId &&
(() => {
const Registry = getRegistryAffordanceTool('slab', 'boundary-edit')
return Registry ? (
<Suspense fallback={null}>
<Registry slabId={selectedSlabId} />
</Suspense>
) : null
})()}
{showSlabHoleEditor &&
selectedSlabId &&
editingHole &&
(() => {
const Registry = getRegistryAffordanceTool('slab', 'hole-edit')
return Registry ? (
<Suspense fallback={null}>
<Registry holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
</Suspense>
) : null
})()}
{showCeilingBoundaryEditor &&
selectedCeilingId &&
(() => {
const Registry = getRegistryAffordanceTool('ceiling', 'boundary-edit')
return Registry ? (
<Suspense fallback={null}>
<Registry ceilingId={selectedCeilingId} />
</Suspense>
) : null
})()}
{showCeilingHoleEditor &&
selectedCeilingId &&
editingHole &&
(() => {
const Registry = getRegistryAffordanceTool('ceiling', 'hole-edit')
return Registry ? (
<Suspense fallback={null}>
<Registry ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
</Suspense>
) : null
})()}
{movingWallEndpoint &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(
movingWallEndpoint.wall.type,
'move-endpoint',
)
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance target={movingWallEndpoint} />
</Suspense>
) : null
})()}
{movingFenceEndpoint &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(
movingFenceEndpoint.fence.type,
'move-endpoint',
)
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance target={movingFenceEndpoint} />
</Suspense>
) : null
})()}
{curvingWall &&
(() => {
const Registry = getRegistryAffordanceTool(curvingWall.type, 'curve')
return Registry ? (
<Suspense fallback={null}>
<Registry node={curvingWall} />
</Suspense>
) : null
})()}
{curvingFence &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance node={curvingFence} />
</Suspense>
) : null
})()}
{movingNode && movingNode.type !== 'building' && (
<MoveTool
onNodeMoved={handlePlacedNodeSelected}
onSpawnMoved={handlePlacedNodeSelected}
/>
)}
{!movingNode && showBuildTool && tool === 'spawn' && (
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
{/* Registry-first: when the active tool's kind has a registered
NodeDefinition with a tool contribution, mount it here. */}
{!movingNode && useRegistryTool && RegistryToolComponent && (
<Suspense fallback={null}>
<RegistryToolComponent />
</Suspense>
)}
{!movingNode && showBuildTool && tool === 'column' && (
{!movingNode && !useRegistryTool && showBuildTool && tool === 'column' && (
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && showBuildTool && tool === 'elevator' && (
{!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && (
<ElevatorTool
buildingId={buildingId as BuildingNode['id'] | null}
levelId={activeLevelId ?? null}
onPlaced={handlePlacedElevatorSelected}
/>
)}
{!movingNode &&
BuildToolComponent &&
tool !== 'spawn' &&
tool !== 'column' &&
tool !== 'elevator' ? (
{!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? (
<BuildToolComponent />
) : null}
</group>
@@ -33,6 +33,7 @@ export const tools: ToolConfig[] = [
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
]
export function StructureTools() {
@@ -1,20 +0,0 @@
import { ShortcutToken } from '../primitives/shortcut-token'
export function CeilingHelper() {
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Add point</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Shift" />
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,13 +1,12 @@
'use client'
import { nodeRegistry } from '@pascal-app/core'
import { useIsMobile } from '../../../hooks/use-mobile'
import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper'
import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper'
import { RegisteredToolHelper } from './registered-tool-helper'
import { RoofHelper } from './roof-helper'
import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper'
export function HelperManager() {
const mode = useEditor((s) => s.mode)
@@ -27,19 +26,19 @@ export function HelperManager() {
return null
}
// Show appropriate helper based on current tool
switch (tool) {
case 'wall':
return <WallHelper />
case 'item':
return <ItemHelper />
case 'slab':
return <SlabHelper />
case 'ceiling':
return <CeilingHelper />
case 'roof':
return <RoofHelper />
default:
// Registry-first: kinds with `def.toolHints` render through the generic
// `RegisteredToolHelper`. Today that covers ceiling / door / fence /
// item / shelf / slab / spawn / wall / window.
if (tool) {
const def = nodeRegistry.get(tool)
if (def?.toolHints && def.toolHints.length > 0) {
return <RegisteredToolHelper hints={def.toolHints} />
}
}
// Legacy fallback — only `roof` remains because it hasn't migrated to
// `def.tool` / `def.toolHints` yet (no Stage D port). When roof
// migrates, this switch deletes outright.
if (tool === 'roof') return <RoofHelper />
return null
}
}
@@ -0,0 +1,25 @@
import type { ToolHint } from '@pascal-app/core'
import { ShortcutToken } from '../primitives/shortcut-token'
/**
* Generic helper panel rendered from `def.toolHints` data. Matches the
* visual styling of the hand-written `<WallHelper>` / `<ItemHelper>` /
* etc. so registry-driven kinds get a consistent look without each kind
* writing its own component.
*
* Drops the need for per-kind helper files entirely — kinds declare
* their hints as static data in their `NodeDefinition`.
*/
export function RegisteredToolHelper({ hints }: { hints: ToolHint[] }) {
if (hints.length === 0) return null
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
{hints.map((hint) => (
<div className="flex items-center gap-2 text-sm" key={`${hint.key}:${hint.label}`}>
<ShortcutToken value={hint.key} />
<span className="text-muted-foreground">{hint.label}</span>
</div>
))}
</div>
)
}
@@ -1,20 +0,0 @@
import { ShortcutToken } from '../primitives/shortcut-token'
export function SlabHelper() {
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Add point</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Shift" />
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,20 +0,0 @@
import { ShortcutToken } from '../primitives/shortcut-token'
export function WallHelper() {
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Set wall start / end</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Shift" />
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,229 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type FenceNode,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
type MaterialSchema,
normalizeWallCurveOffset,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
type FenceStyleValue = 'slat' | 'rail' | 'privacy'
type FenceBaseStyleValue = 'grounded' | 'floating'
const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyleValue }[] = [
{ label: 'Slat', value: 'slat' },
{ label: 'Rail', value: 'rail' },
{ label: 'Privacy', value: 'privacy' },
]
const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyleValue }[] = [
{ label: 'Grounded', value: 'grounded' },
{ label: 'Floating', value: 'floating' },
]
export function FencePanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
)
const handleUpdate = useCallback(
(updates: Partial<FenceNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode],
)
const handleUpdateLength = useCallback(
(newLength: number) => {
if (!node || newLength <= 0) return
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const currentLength = Math.sqrt(dx * dx + dz * dz)
if (currentLength === 0) return
const dirX = dx / currentLength
const dirZ = dz / currentLength
const newEnd: [number, number] = [
node.start[0] + dirX * newLength,
node.start[1] + dirZ * newLength,
]
handleUpdate({ end: newEnd })
},
[node, handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
const length = getWallCurveLength(node)
const curveOffset = getClampedWallCurveOffset(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
return (
<PanelWrapper
icon="/icons/build.png"
onClose={handleClose}
title={node.name || 'Fence'}
width={300}
>
<PanelSection title="Style">
<SegmentedControl
onChange={(value) => handleUpdate({ style: value })}
options={FENCE_STYLE_OPTIONS}
value={node.style}
/>
<SegmentedControl
className="mt-2"
onChange={(value) => handleUpdate({ baseStyle: value })}
options={FENCE_BASE_STYLE_OPTIONS}
value={node.baseStyle}
/>
<ToggleControl
checked={node.showInfill ?? true}
className="mt-2"
label="Fence Infill"
onChange={(checked) => handleUpdate({ showInfill: checked })}
/>
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Length"
max={50}
min={0.1}
onChange={handleUpdateLength}
precision={2}
step={0.01}
unit="m"
value={length}
/>
<SliderControl
label="Curve"
max={Math.max(0.01, maxCurveOffset)}
min={-Math.max(0.01, maxCurveOffset)}
onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
precision={2}
step={0.1}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
<SliderControl
label="Height"
max={4}
min={0.4}
onChange={(value) => handleUpdate({ height: Math.max(0.4, value) })}
precision={2}
step={0.05}
unit="m"
value={node.height}
/>
<SliderControl
label="Thickness"
max={0.5}
min={0.03}
onChange={(value) => handleUpdate({ thickness: Math.max(0.03, value) })}
precision={3}
step={0.005}
unit="m"
value={node.thickness}
/>
</PanelSection>
<PanelSection title="Structure">
<SliderControl
label="Base Height"
max={1}
min={0.04}
onChange={(value) => handleUpdate({ baseHeight: Math.max(0.04, value) })}
precision={3}
step={0.01}
unit="m"
value={node.baseHeight}
/>
<SliderControl
label="Top Rail"
max={0.25}
min={0.01}
onChange={(value) => handleUpdate({ topRailHeight: Math.max(0.01, value) })}
precision={3}
step={0.005}
unit="m"
value={node.topRailHeight}
/>
<SliderControl
label="Post Spacing"
max={5}
min={0.2}
onChange={(value) => handleUpdate({ postSpacing: Math.max(0.2, value) })}
precision={2}
step={0.05}
unit="m"
value={node.postSpacing}
/>
<SliderControl
label="Post Size"
max={0.4}
min={0.01}
onChange={(value) => handleUpdate({ postSize: Math.max(0.01, value) })}
precision={3}
step={0.005}
unit="m"
value={node.postSize}
/>
<SliderControl
label="Ground Clear"
max={0.6}
min={0}
onChange={(value) => handleUpdate({ groundClearance: Math.max(0, value) })}
precision={3}
step={0.005}
unit="m"
value={node.groundClearance}
/>
<SliderControl
label="Edge Inset"
max={0.25}
min={0.005}
onChange={(value) => handleUpdate({ edgeInset: Math.max(0.005, value) })}
precision={3}
step={0.005}
unit="m"
value={node.edgeInset}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -24,25 +24,12 @@ import { useCallback, useEffect, useState } from 'react'
import { useIsMobile } from '../../../hooks/use-mobile'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CeilingPanel } from './ceiling-panel'
import { ColumnPanel } from './column-panel'
import { DoorPanel } from './door-panel'
import { ElevatorPanel } from './elevator-panel'
import { FencePanel } from './fence-panel'
import { ItemPanel } from './item-panel'
import { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar'
import { getNodeDisplay } from './node-display'
import { PaintPanel } from './paint-panel'
import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel'
import { SlabPanel } from './slab-panel'
import { SpawnPanel } from './spawn-panel'
import { StairPanel } from './stair-panel'
import { StairSegmentPanel } from './stair-segment-panel'
import { WallPanel } from './wall-panel'
import { WindowPanel } from './window-panel'
type MovableNode =
| ItemNode
@@ -83,38 +70,15 @@ function isMovableNode(node: AnyNode | null): node is MovableNode {
function panelForType(type: string | null) {
if (!type) return null
switch (type) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'roof-segment':
return <RoofSegmentPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'spawn':
return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'fence':
return <FencePanel />
case 'door':
return <DoorPanel />
case 'elevator':
return <ElevatorPanel />
case 'window':
return <WindowPanel />
default:
return null
}
// Every kind now renders through `<ParametricInspector>`, which either
// composes auto-derived editors from `parametrics.groups` or lazy-
// loads the kind-owned panel via `parametrics.customPanel`. The
// hardcoded switch is gone — all per-kind panel layout lives in
// `nodes/src/<kind>/panel.tsx`. The `type` arg is preserved for
// future cases where we might want a non-registry fallback (e.g.
// reference scale, paint mode); leave the function shape intact.
void type
return <ParametricInspector />
}
function MobilePanelLayer({
@@ -7,7 +7,11 @@ import { cn } from '../../../lib/utils'
interface PanelWrapperProps {
title: string
icon?: string
/** Either a URL path (legacy panels pass `/icons/floor.png` etc.,
* rendered via next/image) OR a React node (registry-driven
* inspector renders `<Icon icon="lucide:fence" />` from
* `def.presentation.icon`). */
icon?: string | React.ReactNode
onClose?: () => void
onReset?: () => void
onBack?: () => void
@@ -58,9 +62,18 @@ export function PanelWrapper({
<ChevronLeft className="h-4 w-4" />
</button>
)}
{icon && (
<Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} />
)}
{icon &&
(typeof icon === 'string' ? (
<Image
alt=""
className="shrink-0 object-contain"
height={16}
src={icon}
width={16}
/>
) : (
<span className="flex shrink-0 items-center justify-center">{icon}</span>
))}
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
{title}
</h2>
@@ -0,0 +1,376 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type IconRef,
nodeRegistry,
type ParamField,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Icon } from '@iconify/react'
import { Move, Trash2 } from 'lucide-react'
import { type ComponentType, lazy, Suspense, useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
/**
* Auto-derived right-panel inspector for any registry-backed node.
*
* Reads `definition.parametrics` from the registry and renders one
* `<PanelSection>` per group, one control per field. Field kinds supported:
* - `number` → SliderControl with min/max/step/unit from the descriptor
* - `enum` → dark-themed `<select>`
* - `color` → native color picker + hex input
* - `vec3` → three SliderControls for X / Y / Z
*
* Generic Actions section appends Move / Delete based on `capabilities`.
*
* Phase 4 will expand this with per-field `customEditor` support and a
* `parametrics.customPanel?` escape hatch for kinds whose parametric editor
* can't be auto-generated (topology editors etc.).
*/
export function ParametricInspector() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
const setSelection = useViewer((s) => s.setSelection)
// Subscribe only to the *type* — a string primitive that doesn't change
// when slider values change. Without this, every updateNode tick during
// a drag re-renders the entire panel + every field + every SliderControl.
// Per-field subscriptions live on FieldRenderer below.
const nodeType = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
const def = nodeType ? nodeRegistry.get(nodeType) : undefined
const parametrics = def?.parametrics
const handleUpdate = useCallback(
(patch: Partial<AnyNode>) => {
if (!selectedId) return
useScene.getState().updateNode(selectedId, patch)
},
[selectedId],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMove = useCallback(() => {
if (!selectedId) return
const node = useScene.getState().nodes[selectedId]
if (!node) return
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(node as any)
setSelection({ selectedIds: [] })
}, [selectedId, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId) return
sfxEmitter.emit('sfx:structure-delete')
useScene.getState().deleteNode(selectedId)
setSelection({ selectedIds: [] })
}, [selectedId, setSelection])
if (!selectedId || !def || !parametrics) return null
// `parametrics.customPanel` escape hatch — kind owns its panel
// entirely (loaded lazily so the bundle isn't eager). Used by kinds
// whose editor has non-parametric concerns (slab holes list, ceiling
// height presets, etc.) until per-field `customEditor` + missing
// field kinds (list/action/computed) graduate the auto-derived
// panel to cover them.
if (parametrics.customPanel) {
const CustomPanel = resolveCustomPanel(parametrics.customPanel)
return (
<Suspense fallback={null}>
<CustomPanel />
</Suspense>
)
}
const presentation = def.presentation
const title = presentation?.label ?? nodeType ?? ''
const iconNode = renderIcon(presentation?.icon)
const canMove = !!def.capabilities.movable
const canDelete = def.capabilities.deletable !== false
return (
<PanelWrapper icon={iconNode} onClose={handleClose} title={title} width={320}>
{parametrics.groups.map((group, gi) => (
<PanelSection key={`group-${gi}`} title={group.label}>
{group.fields.map((field, fi) => (
<FieldRenderer
key={`field-${gi}-${fi}-${String(field.key)}`}
field={field as ParamField<AnyNode>}
nodeId={selectedId}
onUpdate={handleUpdate}
/>
))}
</PanelSection>
))}
{(canMove || canDelete) && (
<PanelSection title="Actions">
<ActionGroup>
{canMove && (
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
)}
{canDelete && (
<ActionButton
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
icon={<Trash2 className="h-4 w-4" />}
label="Delete"
onClick={handleDelete}
/>
)}
</ActionGroup>
</PanelSection>
)}
</PanelWrapper>
)
}
function renderIcon(ref: IconRef | undefined): React.ReactNode | undefined {
if (!ref) return undefined
if (ref.kind === 'url') {
// Plain <img> here so the inspector doesn't pull in next/image's
// server-only requirements (the file is `'use client'`). Same
// 16x16 box the legacy panels use.
return <img alt="" className="h-4 w-4 shrink-0 object-contain" src={ref.src} />
}
if (ref.kind === 'iconify') {
return <Icon height={16} icon={ref.name} width={16} />
}
if (ref.kind === 'svg') {
return (
<svg height={16} viewBox={ref.viewBox} width={16}>
<path d={ref.path} fill="currentColor" />
</svg>
)
}
// `component`: lazy-loaded custom icon component. Suspense-safe.
const LazyIcon = lazy(ref.module)
return (
<Suspense fallback={null}>
<LazyIcon />
</Suspense>
)
}
// Cache lazy custom panel components by their loader so React.lazy isn't
// re-invoked across renders.
const customPanelCache = new WeakMap<() => Promise<unknown>, ComponentType>()
function resolveCustomPanel(loader: () => Promise<{ default: ComponentType<any> }>): ComponentType {
const cached = customPanelCache.get(loader)
if (cached) return cached
const Comp = lazy(loader)
customPanelCache.set(loader, Comp as ComponentType)
return Comp as ComponentType
}
// ─── Per-field renderers ─────────────────────────────────────────────
interface FieldRendererProps {
field: ParamField<AnyNode>
nodeId: AnyNodeId
onUpdate: (patch: Partial<AnyNode>) => void
}
function FieldRenderer({ field, nodeId, onUpdate }: FieldRendererProps) {
const key = String(field.key)
// Subscribe only to this field's value. Zustand compares with ===, so when
// another field on the same node changes (which produces a new node object
// reference), this primitive value stays equal and the field doesn't
// re-render. Vec3 arrays get a new reference only when the array itself
// changes — same outcome.
const value = useScene((s) => {
const n = s.nodes[nodeId]
return n ? (n as Record<string, unknown>)[key] : undefined
})
// visibleIf may consult other fields on the node — subscribe to its boolean
// result so we re-evaluate when relevant.
const visible = useScene((s) => {
const visibleIf = (field as { visibleIf?: (n: AnyNode) => boolean }).visibleIf
if (!visibleIf) return true
const n = s.nodes[nodeId]
return n ? visibleIf(n as AnyNode) : false
})
if (!visible) return null
switch (field.kind) {
case 'number': {
const num = typeof value === 'number' ? value : 0
const step = field.step ?? 0.01
const precision = precisionForStep(step)
return (
<SliderControl
label={prettifyKey(key)}
max={field.max}
min={field.min}
onChange={(next) => onUpdate({ [key]: next } as Partial<AnyNode>)}
precision={precision}
step={step}
unit={field.unit ?? ''}
value={num}
/>
)
}
case 'boolean': {
const checked = value === true
return (
<ToggleControl
checked={checked}
label={prettifyKey(key)}
onChange={(next) => onUpdate({ [key]: next } as Partial<AnyNode>)}
/>
)
}
case 'enum': {
const str = typeof value === 'string' ? value : (field.options[0] ?? '')
if (field.display === 'segmented') {
return (
<SegmentedControl
onChange={(next) => onUpdate({ [key]: next } as Partial<AnyNode>)}
options={field.options.map((opt) => ({ label: prettifyEnumValue(opt), value: opt }))}
value={str}
/>
)
}
return (
<div className="flex items-center justify-between px-3 py-2">
<span className="text-foreground/80 text-xs">{prettifyKey(key)}</span>
<select
className="rounded-md border border-border/50 bg-[#2C2C2E] px-2 py-1 text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
onChange={(e) => onUpdate({ [key]: e.target.value } as Partial<AnyNode>)}
value={str}
>
{field.options.map((opt) => (
<option key={opt} value={opt}>
{prettifyEnumValue(opt)}
</option>
))}
</select>
</div>
)
}
case 'color': {
const str = typeof value === 'string' ? value : '#888888'
return (
<div className="flex items-center justify-between px-3 py-2">
<span className="text-foreground/80 text-xs">{prettifyKey(key)}</span>
<div className="flex items-center gap-2">
<input
className="h-6 w-8 cursor-pointer rounded border border-border/50 bg-transparent"
onChange={(e) => onUpdate({ [key]: e.target.value } as Partial<AnyNode>)}
type="color"
value={str}
/>
<input
className="w-20 rounded-md border border-border/50 bg-[#2C2C2E] px-2 py-1 text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
onChange={(e) => onUpdate({ [key]: e.target.value } as Partial<AnyNode>)}
type="text"
value={str}
/>
</div>
</div>
)
}
case 'vec3': {
const v = Array.isArray(value) && value.length >= 3
? (value as [number, number, number])
: [0, 0, 0]
const axes: Array<{ label: string; index: 0 | 1 | 2 }> = [
{ label: 'X', index: 0 },
{ label: 'Y', index: 1 },
{ label: 'Z', index: 2 },
]
return (
<>
{axes.map(({ label, index }) => {
// v is a [number, number, number] tuple; the explicit local
// resolves TS's noUncheckedIndexedAccess concern that v[index]
// could be undefined.
const axisValue = v[index] ?? 0
return (
<SliderControl
key={`${key}-${label}`}
label={label}
max={axisValue + 5}
min={axisValue - 5}
onChange={(next) => {
const updated = [...v] as [number, number, number]
updated[index] = next
onUpdate({ [key]: updated } as Partial<AnyNode>)
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(axisValue * 100) / 100}
/>
)
})}
</>
)
}
case 'custom':
// The field owns its rendering and update logic — used for
// derived values (length from start/end), dynamic-bounded
// sliders (curve sagitta), composed editors.
return <CustomFieldRenderer Comp={field.component} nodeId={nodeId} onUpdate={onUpdate} />
default:
// material / ref / unrecognized kinds — not implemented in v1.
return null
}
}
function CustomFieldRenderer({
Comp,
nodeId,
onUpdate,
}: {
Comp: ComponentType<{ node: AnyNode; onUpdate: (patch: Partial<AnyNode>) => void }>
nodeId: AnyNodeId
onUpdate: (patch: Partial<AnyNode>) => void
}) {
// Subscribe to the full node — the custom editor may read any
// field. Tools that don't want this churn should write narrower
// selectors inside Comp itself.
const node = useScene((s) => s.nodes[nodeId])
if (!node) return null
return <Comp node={node} onUpdate={onUpdate} />
}
// ─── helpers ─────────────────────────────────────────────────────────
function precisionForStep(step: number): number {
if (step <= 0) return 0
return Math.max(0, Math.ceil(-Math.log10(step)))
}
function prettifyKey(key: string): string {
// 'bracketStyle' → 'Bracket style'
const spaced = key.replace(/([A-Z])/g, ' $1').toLowerCase()
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}
function prettifyEnumValue(value: string): string {
// 'minimal' → 'Minimal'; 'roof-segment' → 'Roof segment'
return value
.split(/[-_\s]/)
.map((word, i) =>
i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word.toLowerCase(),
)
.join(' ')
}
@@ -130,8 +130,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1]
area -= polygon[j]?.[0] * polygon[i]?.[1]
const pi = polygon[i]
const pj = polygon[j]
if (!(pi && pj)) continue
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
return Math.abs(area) / 2
@@ -38,7 +38,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
return (
<TreeNodeWrapper
actions={<TreeNodeActions node={node} />}
actions={<TreeNodeActions nodeId={node.id} />}
depth={depth}
expanded={false}
hasChildren={false}
@@ -53,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
<InlineRenameInput
defaultName="Fence"
isEditing={isEditing}
node={node}
nodeId={node.id}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
@@ -0,0 +1,128 @@
'use client'
import { type AnyNodeId, type ShelfNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
interface ShelfTreeNodeProps {
nodeId: ShelfNode['id']
depth: number
isLast?: boolean
}
/**
* Sidebar tree entry for shelf. Mirrors `item-tree-node`'s shape so the
* shelf's hosted items list as collapsible children — same pattern items
* use for their nested items. The shelf has its own `children: ItemNode[`id`]`
* field on the schema; items reparent into it via `def.surfaces` + the
* placement coordinator's shelf strategy.
*/
export const ShelfTreeNode = memo(function ShelfTreeNode({
nodeId,
depth,
isLast,
}: ShelfTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const [expanded, setExpanded] = useState(true)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const children = useScene(
useShallow((s) => (s.nodes[nodeId] as ShelfNode | undefined)?.children ?? []),
)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
// Expand when a descendant is selected — same imperative subscription
// the item tree-node uses, so we don't re-render when unrelated
// selection-state ticks.
useEffect(() => {
return useViewer.subscribe((state) => {
const { selectedIds } = state.selection
if (selectedIds.length === 0) return
const nodes = useScene.getState().nodes
for (const id of selectedIds) {
let current = nodes[id as AnyNodeId]
while (current?.parentId) {
if (current.parentId === nodeId) {
setExpanded(true)
return
}
current = nodes[current.parentId as AnyNodeId]
}
}
})
}, [nodeId])
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
},
[nodeId, setSelection],
)
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const hasChildren = children.length > 0
return (
<TreeNodeWrapper
actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth}
expanded={expanded}
hasChildren={hasChildren}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/shelf.png" width={14} />
}
isHovered={isHovered}
isLast={isLast}
isSelected={isSelected}
isVisible={isVisible}
label={
<InlineRenameInput
defaultName="Shelf"
isEditing={isEditing}
nodeId={nodeId}
onStartEditing={handleStartEditing}
onStopEditing={handleStopEditing}
/>
}
nodeId={nodeId}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onToggle={handleToggle}
>
{hasChildren &&
children.map((childId, index) => (
<TreeNode
depth={depth + 1}
isLast={index === children.length - 1}
key={childId}
nodeId={childId}
/>
))}
</TreeNodeWrapper>
)
})
@@ -91,8 +91,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1]
area -= polygon[j]?.[0] * polygon[i]?.[1]
const pi = polygon[i]
const pj = polygon[j]
if (!(pi && pj)) continue
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
return Math.abs(area) / 2
@@ -63,6 +63,7 @@ import { FenceTreeNode } from './fence-tree-node'
import { ItemTreeNode } from './item-tree-node'
import { LevelTreeNode } from './level-tree-node'
import { RoofTreeNode } from './roof-tree-node'
import { ShelfTreeNode } from './shelf-tree-node'
import { SlabTreeNode } from './slab-tree-node'
import { SpawnTreeNode } from './spawn-tree-node'
import { StairTreeNode } from './stair-tree-node'
@@ -76,47 +77,61 @@ interface TreeNodeProps {
isLast?: boolean
}
// Per-kind tree-node components keyed by `node.type`. Lookup replaces
// the legacy switch — adding a kind to this map is now the only edit
// needed in this file (the switch's `case '<kind>':` clauses were
// flagged by the Phase 6 grep gate as the last per-kind dispatch
// outside the registry; future work moves these to a
// `def.presentation`-driven generic tree-node and removes this map
// entirely).
const treeNodeByType: Record<
string,
React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }>
> = {
building: BuildingTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
ceiling: CeilingTreeNode,
column: ColumnTreeNode,
elevator: ElevatorTreeNode,
level: LevelTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
shelf: ShelfTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
slab: SlabTreeNode,
spawn: SpawnTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
wall: WallTreeNode,
fence: FenceTreeNode,
roof: RoofTreeNode,
stair: StairTreeNode,
door: DoorTreeNode,
window: WindowTreeNode,
zone: ZoneTreeNode as React.ComponentType<{
depth: number
isLast?: boolean
nodeId: AnyNodeId
}>,
item: ItemTreeNode,
}
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
const nodeType = useScene((state) => state.nodes[nodeId]?.type)
if (!nodeType) return null
switch (nodeType) {
case 'building':
return (
<BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
)
case 'ceiling':
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'column':
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'elevator':
return <ElevatorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'level':
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
case 'slab':
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'spawn':
return <SpawnTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `spawn_${string}`} />
case 'wall':
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'fence':
return <FenceTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'roof':
return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'stair':
return <StairTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'item':
return <ItemTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'door':
return <DoorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'window':
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'zone':
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `zone_${string}`} />
default:
return null
}
const Component = treeNodeByType[nodeType]
if (!Component) return null
return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
})
interface TreeNodeWrapperProps {
@@ -12,7 +12,7 @@ export const ViewerZoneSystem = () => {
const structureLayer = useEditor.getState().structureLayer
const nodes = useScene.getState().nodes
sceneRegistry.byType.zone.forEach((id) => {
sceneRegistry.byType.zone!.forEach((id) => {
const obj = sceneRegistry.nodes.get(id)
if (!obj) return
@@ -0,0 +1,124 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type ChildQuery,
createDragSession,
createSceneApi,
type DragAction,
type DragSessionInput,
emitter,
type GridEvent,
type Modifiers,
type SpatialQuery,
useScene,
} from '@pascal-app/core'
import { useEffect, useRef } from 'react'
const sceneApi = createSceneApi(useScene)
function modifiersFromGridEvent(event: GridEvent): Modifiers {
const ne = event.nativeEvent?.nativeEvent as Partial<KeyboardEvent> | undefined
return {
shift: ne?.shiftKey ?? false,
alt: ne?.altKey ?? false,
ctrl: ne?.ctrlKey ?? false,
meta: ne?.metaKey ?? false,
}
}
export type UseDragActionArgs<Ctx, Draft> = {
/** When true the session is live: subscribes to grid events + Esc.
* Flipping to false (or unmount) cancels and cleans up. */
active: boolean
action: DragAction<Ctx, Draft>
/** Captured once at the moment `active` flips to true. */
initial: DragSessionInput
/** Relations cascade plumbing. */
spatialQuery?: SpatialQuery
childQuery?: ChildQuery
/** Fires once after `action.commit` returns true. */
onCommit?: () => void
/** Fires once after `action.cancel` (Esc, unmount, or commit-returns-false). */
onCancel?: () => void
/**
* Milliseconds after activation during which `grid:click` is swallowed.
* Stops the very click that mounted this tool (a DOM button or 3D
* handle elsewhere) from cascading into the grid and immediately
* committing the drag. Defaults to 150ms — matches the legacy guard
* used by every kind-owned tool entered via a click.
*/
activationGraceMs?: number
}
/**
* React hook wrapping the pure `createDragSession` orchestrator with the
* editor's grid event emitter and an Esc-to-cancel keyboard binding.
*
* - Pauses scene history when active → resumes on commit/cancel/unmount
* - Per `grid:move` runs preview + snap + apply and cascades dirty marks
* - `grid:click` triggers commit; Escape triggers cancel
*
* For tests of the underlying behavior, drive `createDragSession` directly
* (no React needed). This hook is the thin glue.
*/
export function useDragAction<Ctx, Draft>(args: UseDragActionArgs<Ctx, Draft>) {
// Stable refs so handlers don't re-bind when callbacks change.
const argsRef = useRef(args)
argsRef.current = args
useEffect(() => {
if (!args.active) return
const session = createDragSession<Ctx, Draft>(argsRef.current.action, sceneApi, {
spatialQuery: argsRef.current.spatialQuery,
childQuery: argsRef.current.childQuery,
onCommit: () => argsRef.current.onCommit?.(),
onCancel: () => argsRef.current.onCancel?.(),
})
session.start(argsRef.current.initial)
const activatedAt = Date.now()
const graceMs = argsRef.current.activationGraceMs ?? 150
const onMove = (event: GridEvent) => {
const point: readonly [number, number] = [event.localPosition[0], event.localPosition[2]]
session.move(point, modifiersFromGridEvent(event))
}
const onClick = (event: GridEvent) => {
// Swallow the click that mounted this tool — otherwise the very
// first grid:click cascades into commit() before any move().
if (Date.now() - activatedAt < graceMs) {
event.nativeEvent?.stopPropagation?.()
return
}
session.commit()
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') session.cancel()
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onKeyDown)
}
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onKeyDown)
}
// If the parent flipped `active` to false (or unmounted) while we were
// still mid-drag, treat it as a cancel — no dangling history pause.
session.dispose()
}
}, [args.active])
}
export type { AnyNode, AnyNodeId, DragAction, Modifiers }
+147 -2
View File
@@ -4,12 +4,109 @@ export {
type SnapshotCameraData,
ThumbnailGenerator,
} from './components/editor/thumbnail-generator'
// SVG path builders for arc / annular-sector / arrow-head shapes —
// inlined into `kind: 'path'` / `kind: 'polygon'` primitives by curved
// stair rendering in `nodes/src/stair/floorplan.ts`.
export {
buildSvgAnnularSectorPath,
buildSvgArcPath,
buildSvgArrowHeadPoints,
getArcPlanPoint,
} from './components/editor-2d/svg-paths'
// Phase 5 Stage D transitional exports — pure drafting / angle helpers
// consumed by kind-owned drag actions in @pascal-app/nodes. Stage F
// cleanup moves these into @pascal-app/nodes (fence/drafting.ts +
// shared/segment-angle.ts) once every Stage D port is in.
export {
createFenceOnCurrentLevel,
type FencePlanPoint,
snapFenceDraftPoint,
} from './components/tools/fence/fence-drafting'
// Placement-math helpers — shared by kind-owned placement tools in
// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement,
// item drop) so kinds don't reach into editor internals.
export {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToGrid,
snapToHalf,
snapUpToGridStep,
stripTransient,
} from './components/tools/item/placement-math'
export type { PlacementState } from './components/tools/item/placement-types'
// Item placement / move primitives. Re-exported here so the registry-driven
// item move-tool in `@pascal-app/nodes` can compose them — same hooks the
// legacy `MoveItemContent` + `ItemTool` use. Once item placement is fully
// owned by `nodes`, these can be inlined there and dropped from editor.
export { type DraftNodeHandle, useDraftNode } from './components/tools/item/use-draft-node'
export {
type PlacementCoordinatorConfig,
usePlacementCoordinator,
} from './components/tools/item/use-placement-coordinator'
export { CursorSphere } from './components/tools/shared/cursor-sphere'
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
export {
PolygonEditor,
type PolygonEditorProps,
} from './components/tools/shared/polygon-editor'
export {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from './components/tools/shared/segment-angle'
// Stair placement defaults — used by the kind-owned stair / stair-segment
// panels. Re-exported from `components/tools/stair/stair-defaults.ts`.
export {
DEFAULT_CURVED_STAIR_INNER_RADIUS,
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
DEFAULT_SPIRAL_TOP_LANDING_MODE,
DEFAULT_STAIR_ATTACHMENT_SIDE,
DEFAULT_STAIR_FILL_TO_FLOOR,
DEFAULT_STAIR_HEIGHT,
DEFAULT_STAIR_LENGTH,
DEFAULT_STAIR_RAILING_HEIGHT,
DEFAULT_STAIR_RAILING_MODE,
DEFAULT_STAIR_STEP_COUNT,
DEFAULT_STAIR_THICKNESS,
DEFAULT_STAIR_TYPE,
DEFAULT_STAIR_WIDTH,
} from './components/tools/stair/stair-defaults'
export {
createWallOnCurrentLevel,
getWallGridStep,
isWallLongEnough,
snapPointToGrid,
snapScalarToGrid,
snapWallDraftPoint,
type WallPlanPoint,
} from './components/tools/wall/wall-drafting'
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
export { useCommandPalette } from './components/ui/command-palette'
export { ActionButton, ActionGroup } from './components/ui/controls/action-button'
export { MaterialPicker } from './components/ui/controls/material-picker'
export { MetricControl } from './components/ui/controls/metric-control'
export { PanelSection } from './components/ui/controls/panel-section'
export { SegmentedControl } from './components/ui/controls/segmented-control'
export { SliderControl } from './components/ui/controls/slider-control'
export { ToggleControl } from './components/ui/controls/toggle-control'
export { FloatingLevelSelector } from './components/ui/floating-level-selector'
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
// Item collections UI — used by the kind-owned ItemPanel in nodes/.
export { CollectionsPopover } from './components/ui/panels/collections/collections-popover'
// Phase 5 Stage E — kinds with bespoke editors (slab holes list,
// ceiling height presets, etc.) use `parametrics.customPanel` to mount
// a kind-owned panel and need PanelWrapper for the chrome.
export { PanelWrapper } from './components/ui/panels/panel-wrapper'
// Presets popover — used by kind-owned door / window panels for their
// hardware / type / opening presets.
export { PresetsPopover } from './components/ui/panels/presets/presets-popover'
export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
export { useSidebarStore } from './components/ui/primitives/sidebar'
export { Slider } from './components/ui/primitives/slider'
@@ -24,14 +121,62 @@ export {
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
export { PresetsProvider } from './contexts/presets-context'
export { PresetsProvider, usePresetsAdapter } from './contexts/presets-context'
export type { SaveStatus } from './hooks/use-auto-save'
// useDragAction is the React-side glue for the registry's DragAction
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
// can express their affordances declaratively in their own folder.
export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
export { markToolCancelConsumed } from './hooks/use-keyboard'
export { EDITOR_LAYER } from './lib/constants'
// Helper libs used by the kind-owned roof / stair / elevator panels.
export {
resolveCurrentBuildingId,
resolveElevatorNodeSupportY,
resolveElevatorSupportLevelId,
resolveElevatorSupportY,
} from './lib/elevator-support'
// Floor-plan stair helpers — the cumulative-transform walk
// (`computeFloorplanStairSegmentTransforms`) and the rich segment-entry
// builder (`buildFloorplanStairEntry`) used by the kind-owned stair
// floor-plan emitter in `@pascal-app/nodes/src/stair/floorplan.ts`.
// Each flight's transform depends on every prior sibling's length /
// height / `attachmentSide`, so individual stair-segments can't compute
// their own polygon in isolation — the stair (parent) owns the
// computation and emits the whole stack as one registry entry.
export {
buildFloorplanStairEntry,
type FloorplanStairArrowEntry,
type FloorplanStairEntry,
type FloorplanStairSegmentEntry,
} from './lib/floorplan'
export {
buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
buildWallSurfaceMaterialPatch,
getActivePaintMaterialLabel,
hasActivePaintMaterial,
} from './lib/material-paint'
export { duplicateRoofSubtree } from './lib/roof-duplication'
export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus'
export { duplicateStairSubtree } from './lib/stair-duplication'
// `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/
// nodes` so they don't need their own copy / their own tailwind-merge
// dependency.
export { cn } from './lib/utils'
export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
export type { FloorplanSelectionTool, SplitOrientation, ViewMode } from './store/use-editor'
export type {
FloorplanSelectionTool,
MovingFenceEndpoint,
MovingWallEndpoint,
SplitOrientation,
ViewMode,
} from './store/use-editor'
export { default as useEditor } from './store/use-editor'
export {
type PaletteView,
+54 -42
View File
@@ -33,50 +33,62 @@ function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
return true
}
function stripMaterials(node: AnyNode): AnyNode {
const next = { ...node } as Record<string, unknown>
switch (node.type) {
case 'wall':
delete next.material
delete next.materialPreset
delete next.interiorMaterial
delete next.interiorMaterialPreset
delete next.exteriorMaterial
delete next.exteriorMaterialPreset
break
case 'slab':
case 'ceiling':
case 'fence':
case 'roof-segment':
case 'stair-segment':
case 'window':
case 'door':
delete next.material
delete next.materialPreset
break
case 'roof':
delete next.material
delete next.materialPreset
delete next.topMaterial
delete next.topMaterialPreset
delete next.edgeMaterial
delete next.edgeMaterialPreset
delete next.wallMaterial
delete next.wallMaterialPreset
break
case 'stair':
delete next.material
delete next.materialPreset
delete next.railingMaterial
delete next.railingMaterialPreset
delete next.treadMaterial
delete next.treadMaterialPreset
delete next.sideMaterial
delete next.sideMaterialPreset
break
/**
* Material field keys per kind, used by the `structure` duplicate preset
* to strip materials from the cloned subtree. Lookup table replaces the
* legacy per-kind switch — the Phase 6 grep gate flagged `case '<kind>':`
* in this file as the remaining per-kind dispatch outside the registry.
*
* Future: move this to a `capabilities.materialFields` declaration on
* each kind's `NodeDefinition` so adding a new kind with materials is a
* registry-only edit. Today the registry doesn't surface material fields
* in a uniform way (each kind's panel reads / writes them directly), so
* this map mirrors the legacy behavior 1:1.
*/
const MATERIAL_FIELDS_BY_KIND: Record<string, ReadonlyArray<string>> = {
wall: [
'material',
'materialPreset',
'interiorMaterial',
'interiorMaterialPreset',
'exteriorMaterial',
'exteriorMaterialPreset',
],
slab: ['material', 'materialPreset'],
ceiling: ['material', 'materialPreset'],
fence: ['material', 'materialPreset'],
shelf: ['material', 'materialPreset'],
'roof-segment': ['material', 'materialPreset'],
'stair-segment': ['material', 'materialPreset'],
window: ['material', 'materialPreset'],
door: ['material', 'materialPreset'],
roof: [
'material',
'materialPreset',
'topMaterial',
'topMaterialPreset',
'edgeMaterial',
'edgeMaterialPreset',
'wallMaterial',
'wallMaterialPreset',
],
stair: [
'material',
'materialPreset',
'railingMaterial',
'railingMaterialPreset',
'treadMaterial',
'treadMaterialPreset',
'sideMaterial',
'sideMaterialPreset',
],
}
function stripMaterials(node: AnyNode): AnyNode {
const fields = MATERIAL_FIELDS_BY_KIND[node.type]
if (!fields) return node
const next = { ...node } as Record<string, unknown>
for (const field of fields) delete next[field]
return next as AnyNode
}
+9 -3
View File
@@ -13,6 +13,7 @@ import {
type MaterialTarget,
type RoofNode,
type RoofSurfaceMaterialRole,
type ShelfNode,
type SlabNode,
type StairNode,
type StairSurfaceMaterialRole,
@@ -22,7 +23,7 @@ import {
export type PaintableMaterialTarget = Extract<
MaterialTarget,
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling'
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' | 'shelf'
>
export type SingleSurfaceMaterialRole = 'surface'
@@ -133,7 +134,7 @@ export function buildStairSurfaceMaterialPatch(
}
export function buildSingleSurfaceMaterialPatch<
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
return {
material,
@@ -222,7 +223,8 @@ export function resolveActivePaintMaterialFromSelection(params: {
(selectedNode.type === 'fence' ||
selectedNode.type === 'column' ||
selectedNode.type === 'slab' ||
selectedNode.type === 'ceiling') &&
selectedNode.type === 'ceiling' ||
selectedNode.type === 'shelf') &&
selectedMaterialTarget.role === 'surface'
) {
const target = selectedNode.type
@@ -280,5 +282,9 @@ export function resolvePaintTargetFromSelection(params: {
return 'ceiling'
}
if (selectedNode.type === 'shelf') {
return 'shelf'
}
return null
}
+6 -2
View File
@@ -283,7 +283,11 @@ export function syncEditorSelectionFromCurrentScene() {
if (shouldRestoreEditorUiState) {
if (restoredSelection) {
useViewer.getState().setSelection(restoredSelection)
// PersistedSelectionPath carries plain `string` ids (read from
// localStorage, no branded-template-literal guarantee). The viewer's
// SelectionPath expects branded ids. The runtime values match the
// brand; the cast bridges the static gap.
useViewer.getState().setSelection(restoredSelection as never)
useEditor.setState(
restoredEditorUiState.phase === 'site'
? (selectionDrivenEditorUiState ?? restoredEditorUiState)
@@ -305,7 +309,7 @@ export function syncEditorSelectionFromCurrentScene() {
}
if (restoredSelection) {
useViewer.getState().setSelection(restoredSelection)
useViewer.getState().setSelection(restoredSelection as never)
if (selectionDrivenEditorUiState) {
useEditor.setState(selectionDrivenEditorUiState)
}
+1
View File
@@ -65,6 +65,7 @@ export type StructureTool =
| 'spawn'
| 'window'
| 'door'
| 'shelf'
// Furnish mode tools (items and decoration)
export type FurnishTool = 'item'
+61
View File
@@ -0,0 +1,61 @@
{
"name": "@pascal-app/nodes",
"version": "0.1.0",
"description": "Built-in node bundles for the Pascal 3D editor — one folder per kind, exported as `builtinPlugin`",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch",
"test": "bun test",
"prepublishOnly": "bun run build && bun test"
},
"peerDependencies": {
"@pascal-app/core": "^0.8.0",
"@pascal-app/editor": "^0.8.0",
"@pascal-app/viewer": "^0.8.0",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"lucide-react": "^1",
"react": "^18 || ^19",
"three": "^0.184",
"zustand": "^5"
},
"devDependencies": {
"@pascal-app/core": "^0.8.0",
"@pascal-app/editor": "^0.8.0",
"@pascal-app/viewer": "^0.8.0",
"@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
"typescript": "6.0.2"
},
"keywords": [
"pascal",
"3d",
"editor",
"node-registry"
],
"repository": {
"type": "git",
"url": "https://github.com/pascalorg/editor.git",
"directory": "packages/nodes"
},
"license": "MIT",
"homepage": "https://github.com/pascalorg/editor/tree/main/packages/nodes#readme",
"bugs": "https://github.com/pascalorg/editor/issues"
}
+50
View File
@@ -0,0 +1,50 @@
import { BuildingNode as BuildingNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { buildingParametrics } from './parametrics'
import { BuildingNode } from './schema'
/**
* Building — Stage A. Container for levels; can be translated /
* rotated as a whole (movable + rotatable on Y). The legacy
* `MoveBuildingContent` handles building-wide drag; the registry
* fallback would translate position, which is close to right —
* but kept legacy at Stage A to avoid disturbing the building's
* world-space group transform handling.
*/
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
kind: 'building',
schemaVersion: 1,
schema: BuildingNode,
category: 'site',
defaults: () => {
const stub = BuildingNodeSchema.parse({ id: 'building_default' as never, type: 'building' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
// Building is a container — sidebar / building switcher drive
// selection, never 3D click. Same reasoning as `level` / `site`.
duplicable: false,
deletable: false,
},
parametrics: buildingParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
presentation: {
label: 'Building',
description: 'A building container holding one or more levels.',
icon: { kind: 'url', src: '/icons/building.png' },
paletteSection: 'site',
paletteOrder: 6,
},
mcp: {
description: 'A building container that groups levels.',
},
}
+1
View File
@@ -0,0 +1 @@
export { buildingDefinition } from './definition'

Some files were not shown because too many files have changed in this diff Show More