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' 'use client'
import '../lib/bootstrap'
import { import {
applySceneGraphToEditor, applySceneGraphToEditor,
Editor, 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/core": "*",
"@pascal-app/editor": "*", "@pascal-app/editor": "*",
"@pascal-app/mcp": "*", "@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
"@pascal-app/viewer": "*", "@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7", "@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/core": "*",
"@pascal-app/editor": "*", "@pascal-app/editor": "*",
"@pascal-app/mcp": "*", "@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
"@pascal-app/viewer": "*", "@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
@@ -182,6 +183,28 @@
"@pascal-app/core": "^0.8.0", "@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": { "packages/typescript-config": {
"name": "@repo/typescript-config", "name": "@repo/typescript-config",
"version": "0.0.0", "version": "0.0.0",
@@ -451,6 +474,8 @@
"@pascal-app/mcp": ["@pascal-app/mcp@workspace:packages/mcp"], "@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-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"],
"@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"], "@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/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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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", "import": "./dist/utils/clone-scene-graph.js",
"default": "./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": { "./schema": {
"types": "./dist/schema/index.d.ts", "types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js", "import": "./dist/schema/index.js",
@@ -54,6 +59,8 @@
"scripts": { "scripts": {
"build": "tsc --build", "build": "tsc --build",
"dev": "tsc --build --watch", "dev": "tsc --build --watch",
"test": "bun test",
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"peerDependencies": { "peerDependencies": {
+16 -1
View File
@@ -13,6 +13,8 @@ import type {
LevelNode, LevelNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
ScanNode,
ShelfNode,
SiteNode, SiteNode,
SlabNode, SlabNode,
SpawnNode, SpawnNode,
@@ -35,7 +37,14 @@ export interface GridEvent {
*/ */
localPosition: [number, number, number] localPosition: [number, number, number]
faceIndex?: 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> nativeEvent: ThreeEvent<PointerEvent>
} }
@@ -57,6 +66,7 @@ export type SiteEvent = NodeEvent<SiteNode>
export type BuildingEvent = NodeEvent<BuildingNode> export type BuildingEvent = NodeEvent<BuildingNode>
export type LevelEvent = NodeEvent<LevelNode> export type LevelEvent = NodeEvent<LevelNode>
export type ZoneEvent = NodeEvent<ZoneNode> export type ZoneEvent = NodeEvent<ZoneNode>
export type ShelfEvent = NodeEvent<ShelfNode>
export type SlabEvent = NodeEvent<SlabNode> export type SlabEvent = NodeEvent<SlabNode>
export type SpawnEvent = NodeEvent<SpawnNode> export type SpawnEvent = NodeEvent<SpawnNode>
export type CeilingEvent = NodeEvent<CeilingNode> export type CeilingEvent = NodeEvent<CeilingNode>
@@ -68,6 +78,8 @@ export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode> export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode> export type DoorEvent = NodeEvent<DoorNode>
export type ElevatorEvent = NodeEvent<ElevatorNode> export type ElevatorEvent = NodeEvent<ElevatorNode>
export type ScanEvent = NodeEvent<ScanNode>
export type GuideEvent = NodeEvent<GuideNode>
// Event suffixes - exported for use in hooks // Event suffixes - exported for use in hooks
export const eventSuffixes = [ export const eventSuffixes = [
@@ -189,6 +201,7 @@ type EditorEvents = GridEvents &
NodeEvents<'level', LevelEvent> & NodeEvents<'level', LevelEvent> &
NodeEvents<'zone', ZoneEvent> & NodeEvents<'zone', ZoneEvent> &
NodeEvents<'slab', SlabEvent> & NodeEvents<'slab', SlabEvent> &
NodeEvents<'shelf', ShelfEvent> &
NodeEvents<'spawn', SpawnEvent> & NodeEvents<'spawn', SpawnEvent> &
NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'column', ColumnEvent> & NodeEvents<'column', ColumnEvent> &
@@ -198,6 +211,8 @@ type EditorEvents = GridEvents &
NodeEvents<'stair-segment', StairSegmentEvent> & NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'window', WindowEvent> & NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> & NodeEvents<'door', DoorEvent> &
NodeEvents<'scan', ScanEvent> &
NodeEvents<'guide', GuideEvent> &
CameraControlEvents & CameraControlEvents &
ToolEvents & ToolEvents &
GuideEvents & GuideEvents &
@@ -3,49 +3,61 @@
import { useLayoutEffect } from 'react' import { useLayoutEffect } from 'react'
import type * as THREE from 'three' 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 = { export const sceneRegistry = {
// Master lookup: ID -> Object3D // Master lookup: ID -> Object3D
nodes: new Map<string, THREE.Object3D>(), nodes: new Map<string, THREE.Object3D>(),
// Categorized lookups: Type -> Set of IDs // Categorized lookups: Kind -> Set of IDs. Backed by a Proxy so any kind
// Using a Set is faster for adding/deleting than an Array // gets a Set on first touch — no hardcoded list.
byType: { byType: byTypeProxy,
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>(),
},
/** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */ /** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */
clear() { clear() {
this.nodes.clear() this.nodes.clear()
for (const set of Object.values(this.byType)) { for (const set of byTypeStore.values()) {
set.clear() set.clear()
} }
}, },
} }
export function useRegistry( export function useRegistry(id: string, type: string, ref: React.RefObject<THREE.Object3D>) {
id: string,
type: keyof typeof sceneRegistry.byType,
ref: React.RefObject<THREE.Object3D>,
) {
useLayoutEffect(() => { useLayoutEffect(() => {
const obj = ref.current const obj = ref.current
if (!obj) return if (!obj) return
@@ -53,13 +65,13 @@ export function useRegistry(
// 1. Add to master map // 1. Add to master map
sceneRegistry.nodes.set(id, obj) sceneRegistry.nodes.set(id, obj)
// 2. Add to type-specific set // 2. Add to type-specific set — Proxy auto-creates on first access.
sceneRegistry.byType[type].add(id) sceneRegistry.byType[type]!.add(id)
// 4. Cleanup when component unmounts // 3. Cleanup when component unmounts
return () => { return () => {
sceneRegistry.nodes.delete(id) sceneRegistry.nodes.delete(id)
sceneRegistry.byType[type].delete(id) sceneRegistry.byType[type]!.delete(id)
} }
}, [id, type, ref]) }, [id, type, ref])
} }
@@ -1,11 +1,5 @@
import { import { nodeRegistry } from '../../registry'
type AnyNode, import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
type AnyNodeId,
getScaledDimensions,
type ItemNode,
type SlabNode,
type WallNode,
} from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { import {
itemOverlapsPolygon, itemOverlapsPolygon,
@@ -135,31 +129,35 @@ function markNodesOverlappingSlab(
const slabLevelId = resolveLevelId(slab, nodes) const slabLevelId = resolveLevelId(slab, nodes)
for (const node of Object.values(nodes)) { for (const node of Object.values(nodes)) {
if (node.type === 'item') { if (node.type === 'wall') {
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') {
const wall = node as WallNode const wall = node as WallNode
if (resolveLevelId(node, nodes) !== slabLevelId) continue if (resolveLevelId(node, nodes) !== slabLevelId) continue
if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) { if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) {
markDirty(node.id) markDirty(node.id)
} }
} else if (node.type === 'stair') { continue
}
if (node.type === 'stair') {
if (resolveLevelId(node, nodes) !== slabLevelId) continue if (resolveLevelId(node, nodes) !== slabLevelId) continue
markDirty(node.id) 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, EventSuffix,
FenceEvent, FenceEvent,
GridEvent, GridEvent,
GuideEvent,
ItemEvent, ItemEvent,
LevelEvent, LevelEvent,
NodeEvent, NodeEvent,
RoofEvent, RoofEvent,
RoofSegmentEvent, RoofSegmentEvent,
ScanEvent,
ShelfEvent,
SiteEvent, SiteEvent,
SlabEvent, SlabEvent,
SpawnEvent, SpawnEvent,
@@ -44,10 +47,10 @@ export {
} from './lib/door-operation' } from './lib/door-operation'
export { getRenderableSlabPolygon } from './lib/slab-polygon' export { getRenderableSlabPolygon } from './lib/slab-polygon'
export { export {
type AutoSlabSyncPlan,
detectSpacesForLevel, detectSpacesForLevel,
initSpaceDetectionSync, initSpaceDetectionSync,
planAutoSlabsForLevel, planAutoSlabsForLevel,
type AutoSlabSyncPlan,
type Space, type Space,
wallTouchesOthers, wallTouchesOthers,
} from './lib/space-detection' } from './lib/space-detection'
@@ -63,7 +66,9 @@ export {
type MaterialCategory, type MaterialCategory,
toLibraryMaterialRef, toLibraryMaterialRef,
} from './material-library' } from './material-library'
export * from './registry'
export * from './schema' export * from './schema'
export * from './services'
export { export {
getSceneHistoryPauseDepth, getSceneHistoryPauseDepth,
pauseSceneHistory, 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 { clearSceneHistory, default as useScene } from './store/use-scene'
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
export { export {
type ElevatorDoorSide,
getElevatorCabCenterZ, getElevatorCabCenterZ,
getElevatorCabDepth, getElevatorCabDepth,
getElevatorCabWidth, getElevatorCabWidth,
@@ -101,7 +107,6 @@ export {
getResolvedElevatorDoorPanelStyle, getResolvedElevatorDoorPanelStyle,
getResolvedElevatorDoorStyle, getResolvedElevatorDoorStyle,
getResolvedElevatorShaftStyle, getResolvedElevatorShaftStyle,
type ElevatorDoorSide,
} from './systems/elevator/elevator-geometry' } from './systems/elevator/elevator-geometry'
export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync'
export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system' export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system'
@@ -157,8 +162,8 @@ export {
constrainWallMoveDeltaToAxis, constrainWallMoveDeltaToAxis,
getPerpendicularWallMoveAxis, getPerpendicularWallMoveAxis,
planWallMoveJunctions, planWallMoveJunctions,
type WallMoveBridgePlan,
type WallMoveAxis, type WallMoveAxis,
type WallMoveBridgePlan,
type WallMoveJunctionPlan, type WallMoveJunctionPlan,
type WallPlanPoint, type WallPlanPoint,
} from './systems/wall/wall-move' } 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 { RoofSegmentNode, RoofType } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan' export { ScanNode } from './nodes/scan'
// Nodes // Nodes
export { ShelfNode } from './nodes/shelf'
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab' export { SlabNode } from './nodes/slab'
export { SpawnNode } from './nodes/spawn' export { SpawnNode } from './nodes/spawn'
+1
View File
@@ -51,6 +51,7 @@ export const MaterialTarget = z.enum([
'ceiling', 'ceiling',
'door', 'door',
'window', 'window',
'shelf',
]) ])
export type MaterialTarget = z.infer<typeof MaterialTarget> 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 { RoofNode } from './nodes/roof'
import { RoofSegmentNode } from './nodes/roof-segment' import { RoofSegmentNode } from './nodes/roof-segment'
import { ScanNode } from './nodes/scan' import { ScanNode } from './nodes/scan'
import { ShelfNode } from './nodes/shelf'
import { SiteNode } from './nodes/site' import { SiteNode } from './nodes/site'
import { SlabNode } from './nodes/slab' import { SlabNode } from './nodes/slab'
import { SpawnNode } from './nodes/spawn' import { SpawnNode } from './nodes/spawn'
@@ -34,6 +35,7 @@ export const AnyNode = z.discriminatedUnion('type', [
CeilingNode, CeilingNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
ShelfNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
ScanNode, 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 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]) { if (effectiveParentId && nextNodes[effectiveParentId]) {
const parent = nextNodes[effectiveParentId] const parent = nextNodes[effectiveParentId]
if ('children' in parent) {
// Type Guard: Check if the parent node is a container that supports children const existing = (parent as { children?: unknown }).children
if ('children' in parent && Array.isArray(parent.children)) { const children = Array.isArray(existing) ? (existing as AnyNodeId[]) : []
nextNodes[effectiveParentId] = { nextNodes[effectiveParentId] = {
...parent, ...parent,
// Use Set to prevent duplicate IDs if createNode is called twice children: Array.from(new Set([...children, newNode.id])) as any,
children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here
} }
} }
} else if (!effectiveParentId) { } else if (!effectiveParentId) {
@@ -442,20 +449,31 @@ export const updateNodesAction = (
const oldParentId = currentNode.parentId as AnyNodeId | null const oldParentId = currentNode.parentId as AnyNodeId | null
if (oldParentId && nextNodes[oldParentId]) { if (oldParentId && nextNodes[oldParentId]) {
const oldParent = nextNodes[oldParentId] as AnyContainerNode const oldParent = nextNodes[oldParentId] as AnyContainerNode
const oldChildren = Array.isArray((oldParent as { children?: unknown }).children)
? (oldParent as { children: AnyNodeId[] }).children
: []
nextNodes[oldParent.id] = { nextNodes[oldParent.id] = {
...oldParent, ...oldParent,
children: oldParent.children.filter((childId) => childId !== id), children: oldChildren.filter((childId) => childId !== id),
} as AnyNode } as AnyNode
parentsToUpdate.add(oldParent.id) 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 const newParentId = data.parentId as AnyNodeId | null
if (newParentId && nextNodes[newParentId]) { if (newParentId && nextNodes[newParentId]) {
const newParent = nextNodes[newParentId] as AnyContainerNode const newParent = nextNodes[newParentId] as AnyContainerNode
const newChildren = Array.isArray((newParent as { children?: unknown }).children)
? (newParent as { children: AnyNodeId[] }).children
: []
nextNodes[newParent.id] = { nextNodes[newParent.id] = {
...newParent, ...newParent,
children: Array.from(new Set([...newParent.children, id])), children: Array.from(new Set([...newChildren, id])),
} as AnyNode } as AnyNode
parentsToUpdate.add(newParent.id) 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]) 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') { if (node.type === 'roof') {
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id]) 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) return Math.max(node.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, 0.04)
} }
export function getElevatorShaftWidth( export function getElevatorShaftWidth(node: ElevatorNode, cabWidth = getElevatorCabWidth(node)) {
node: ElevatorNode,
cabWidth = getElevatorCabWidth(node),
) {
return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8) return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8)
} }
export function getElevatorShaftDepth( export function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDepth(node)) {
node: ElevatorNode,
cabDepth = getElevatorCabDepth(node),
) {
return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8) 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', () => { 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({ const moving = stepElevatorRuntimeState({
defaultEntry: entries[0]!, defaultEntry: entries[0]!,
delta: 0.016, delta: 0.016,
@@ -1,7 +1,7 @@
import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema' import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema'
import { type ElevatorInteractiveState, useInteractive } from '../../store/use-interactive' import { type ElevatorInteractiveState, useInteractive } from '../../store/use-interactive'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { resolveElevatorLevels, type ElevatorLevelEntry } from './elevator-service' import { type ElevatorLevelEntry, resolveElevatorLevels } from './elevator-service'
const EPSILON = 0.001 const EPSILON = 0.001
@@ -67,9 +67,7 @@ export function queueElevatorRequest(
} }
} }
export function openElevatorDoorState( export function openElevatorDoorState(state: ElevatorInteractiveState): ElevatorInteractiveState {
state: ElevatorInteractiveState,
): ElevatorInteractiveState {
if (!state.currentLevelId || state.phase === 'moving') return state if (!state.currentLevelId || state.phase === 'moving') return state
return { return {
@@ -246,7 +244,9 @@ export function stepElevatorRuntimes(now: number, delta: number) {
const state = useInteractive.getState().elevators[elevatorId] const state = useInteractive.getState().elevators[elevatorId]
if (!state) { if (!state) {
useInteractive.getState().initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) useInteractive
.getState()
.initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
continue 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 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 movedEndpoint: WallMoveEndpoint
} }
export type WallMoveLinkedWallTargetPlan< export type WallMoveLinkedWallTargetPlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
TWall extends Pick<WallNode, 'id' | 'start' | 'end'>,
> = {
wall: TWall wall: TWall
originalPoint: WallPlanPoint originalPoint: WallPlanPoint
targetPoint: WallPlanPoint targetPoint: WallPlanPoint
@@ -174,7 +172,9 @@ export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start
.sort((a, b) => a.distance - b.distance)[0] .sort((a, b) => a.distance - b.distance)[0]
if (consumedSameDirectionWall) { 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') const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction')
wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall) wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall)
@@ -201,14 +201,22 @@ export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start
const linkedAtPivot = linkedWalls const linkedAtPivot = linkedWalls
.filter( .filter(
(wall) => wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint), (wall) =>
wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint),
) )
.map((wall) => ({ .map((wall) => ({
wall, wall,
relation: getMoveWallRelation(wall, pivotPoint, nextPoint), relation: getMoveWallRelation(wall, pivotPoint, nextPoint),
})) }))
addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true) addStandardEndpointPlan(
endpoint,
pivotPoint,
nextPoint,
linkedAtPivot,
':through-pivot',
true,
)
return 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} fill={curvedAccent}
key={`${stair.id}:spiral-arrow`} key={`${stair.id}:spiral-arrow`}
pointerEvents="none" pointerEvents="none"
points={buildSvgArrowHeadPoints( points={formatSvgPolygonPoints(
buildSvgArrowHeadPoints(
arrowPoint, arrowPoint,
tangentAngle, tangentAngle,
clamp(stair.width * 0.18, 0.12, 0.18), clamp(stair.width * 0.18, 0.12, 0.18),
),
)} )}
/> />
) )
@@ -361,10 +363,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
fill={curvedAccent} fill={curvedAccent}
key={`${stair.id}:curved-arrow`} key={`${stair.id}:curved-arrow`}
pointerEvents="none" pointerEvents="none"
points={buildSvgArrowHeadPoints( points={formatSvgPolygonPoints(
buildSvgArrowHeadPoints(
arrowPoint, arrowPoint,
tangentAngle, tangentAngle,
clamp(stair.width * 0.16, 0.1, 0.16), 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(' ') 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 = { const left = {
x: point.x - size * Math.cos(angle - Math.PI / 6), x: point.x - size * Math.cos(angle - Math.PI / 6),
y: point.y - size * Math.sin(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), y: point.y - size * Math.sin(angle + Math.PI / 6),
} }
return formatSvgPolygonPoints([point, left, right]) return [point, left, right]
} }
export { toSvgPoint, toSvgX, toSvgY } export { toSvgPoint, toSvgX, toSvgY }
@@ -7,7 +7,7 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -4,8 +4,8 @@ import '../../three-types'
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type ElevatorNode,
type ElevatorDoorSide, type ElevatorDoorSide,
type ElevatorNode,
emitter, emitter,
getElevatorCabCenterZ, getElevatorCabCenterZ,
getElevatorCabDepth, getElevatorCabDepth,
@@ -18,10 +18,10 @@ import {
getElevatorShaftWidth, getElevatorShaftWidth,
getResolvedElevatorDoorStyle, getResolvedElevatorDoorStyle,
openElevatorDoor, openElevatorDoor,
requestElevatorLevel,
resolveElevatorBuildingLevels, resolveElevatorBuildingLevels,
resolveElevatorDispatchTarget, resolveElevatorDispatchTarget,
resolveElevatorServiceLevels, resolveElevatorServiceLevels,
requestElevatorLevel,
sceneRegistry, sceneRegistry,
useInteractive, useInteractive,
useScene, useScene,
@@ -352,7 +352,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const meshes: ElevatorColliderMesh[] = [] const meshes: ElevatorColliderMesh[] = []
for (const elevatorId of sceneRegistry.byType.elevator) { for (const elevatorId of sceneRegistry.byType.elevator!) {
const typedElevatorId = elevatorId as AnyNodeId const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId] const node = nodes[typedElevatorId]
if (node?.type !== 'elevator' || node.visible === false) continue if (node?.type !== 'elevator' || node.visible === false) continue
@@ -585,7 +585,7 @@ export const FirstPersonControls = () => {
let closestDoorId: AnyNodeId | null = null let closestDoorId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE 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] const node = nodes[doorId as AnyNodeId]
if (node?.type !== 'door') continue if (node?.type !== 'door') continue
if (node.openingKind === 'opening') continue if (node.openingKind === 'opening') continue
@@ -683,7 +683,7 @@ export const FirstPersonControls = () => {
let closestWindowId: AnyNodeId | null = null let closestWindowId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE 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] const node = nodes[windowId as AnyNodeId]
if (node?.type !== 'window') continue if (node?.type !== 'window') continue
if (node.openingKind === 'opening') continue if (node.openingKind === 'opening') continue
@@ -713,7 +713,7 @@ export const FirstPersonControls = () => {
let closestTarget: FirstPersonInteractableTarget | null = null let closestTarget: FirstPersonInteractableTarget | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE 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 typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId] const node = nodes[typedElevatorId]
if (node?.type !== 'elevator') continue if (node?.type !== 'elevator') continue
@@ -1088,11 +1088,11 @@ export const FirstPersonControls = () => {
const elevatorIds = activeRide const elevatorIds = activeRide
? [ ? [
activeRide.elevatorId, activeRide.elevatorId,
...Array.from(sceneRegistry.byType.elevator).filter( ...Array.from(sceneRegistry.byType.elevator!).filter(
(elevatorId) => elevatorId !== activeRide.elevatorId, (elevatorId) => elevatorId !== activeRide.elevatorId,
), ),
] ]
: Array.from(sceneRegistry.byType.elevator) : Array.from(sceneRegistry.byType.elevator!)
for (const elevatorId of elevatorIds) { for (const elevatorId of elevatorIds) {
const typedElevatorId = elevatorId as AnyNodeId const typedElevatorId = elevatorId as AnyNodeId
@@ -176,7 +176,7 @@ function buildRegisteredNodeTypeLookup() {
const nodeTypes = new Map<string, ColliderNodeType>() const nodeTypes = new Map<string, ColliderNodeType>()
for (const type of COLLIDER_NODE_TYPES) { 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) nodeTypes.set(nodeId, type)
} }
} }
@@ -238,7 +238,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
} }
for (const type of COLLIDER_NODE_TYPES) { 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 if (shouldSkipColliderNode(nodeId, type)) continue
const root = sceneRegistry.nodes.get(nodeId) const root = sceneRegistry.nodes.get(nodeId)
@@ -10,6 +10,8 @@ import {
FenceNode, FenceNode,
generateId, generateId,
ItemNode, ItemNode,
isRegistrySelectable,
nodeRegistry,
RoofSegmentNode, RoofSegmentNode,
type SlabNode, type SlabNode,
SpawnNode, SpawnNode,
@@ -78,7 +80,12 @@ export function FloatingActionMenu() {
// Subscribe just to the selected node so unrelated scene updates do not // Subscribe just to the selected node so unrelated scene updates do not
// re-render this menu. // re-render this menu.
const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null)) 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. // Boolean selector, only re-renders when curving availability actually flips.
const canCurveSelectedWall = useScene((s) => { const canCurveSelectedWall = useScene((s) => {
@@ -195,7 +202,11 @@ export function FloatingActionMenu() {
node.type === 'roof' || node.type === 'roof' ||
node.type === 'roof-segment' || node.type === 'roof-segment' ||
node.type === 'stair' || 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) setMovingNode(node as any)
} }
@@ -289,6 +300,16 @@ export function FloatingActionMenu() {
} else if (node.type === 'spawn') { } else if (node.type === 'spawn') {
duplicate = SpawnNode.parse(duplicateInfo) 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) { } catch (error) {
console.error('Failed to parse duplicate', error) console.error('Failed to parse duplicate', error)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
@@ -331,6 +352,19 @@ export function FloatingActionMenu() {
} }
// Duplicate children for stair nodes // 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 ( if (
duplicate.type === 'item' || duplicate.type === 'item' ||
@@ -342,7 +376,10 @@ export function FloatingActionMenu() {
duplicate.type === 'door' || duplicate.type === 'door' ||
duplicate.type === 'roof-segment' || duplicate.type === 'roof-segment' ||
duplicate.type === 'spawn' || 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) setMovingNode(duplicate as any)
} else if (duplicate.type === 'stair') { } 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 { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' 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 { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard' import { useKeyboard } from '../../hooks/use-keyboard'
import { import {
@@ -7,13 +7,17 @@ import {
emitter, emitter,
type FenceNode, type FenceNode,
getMaterialPresetByRef, getMaterialPresetByRef,
getSelectableKinds,
type ItemNode, type ItemNode,
isRegistrySelectable,
type NodeEvent, type NodeEvent,
nodeRegistry,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
type RoofSegmentEvent, type RoofSegmentEvent,
resolveLevelId, resolveLevelId,
resolveMaterial, resolveMaterial,
type ShelfNode,
type SlabNode, type SlabNode,
type StairEvent, type StairEvent,
type StairNode, type StairNode,
@@ -338,7 +342,7 @@ function applyStairPaintPreview(
} }
function applySingleSurfacePaintPreview( function applySingleSurfacePaintPreview(
node: FenceNode | ColumnNode | SlabNode | CeilingNode, node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
material: ActivePaintMaterial, material: ActivePaintMaterial,
): PaintPreviewCleanup | null { ): PaintPreviewCleanup | null {
if (node.type === 'ceiling') { 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 (!mesh) return null
if (node.type === 'slab') { if (node.type === 'slab') {
@@ -648,6 +669,11 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
} }
if (node.type === 'window' || node.type === 'door') return true 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 return false
}, },
}, },
@@ -676,14 +702,43 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
}, },
isValid: (node) => { isValid: (node) => {
if (!isNodeInCurrentLevel(node)) return false 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 const item = node as ItemNode
return item.asset.category !== 'door' && item.asset.category !== 'window' 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 => { 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') { if (node.type === 'zone') {
return { return {
phase: 'structure', phase: 'structure',
@@ -712,18 +767,16 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
} }
} }
if (node.type === 'item') { // Registry-driven kinds (Phase 5+): route by `def.category`. Built-ins
const item = node as ItemNode // above match before this fallback. Furnish-category kinds (shelf,
if (item.asset.category === 'door' || item.asset.category === 'window') { // item — already handled above) land on the furnish phase; structure-
return { // category kinds (everything else) on structure/elements.
phase: 'structure', const def = nodeRegistry.get(node.type)
structureLayer: 'elements', if (def) {
} if (def.category === 'furnish') {
} return { phase: 'furnish' }
return {
phase: 'furnish',
} }
return { phase: 'structure', structureLayer: 'elements' }
} }
return null return null
@@ -899,7 +952,8 @@ export const SelectionManager = () => {
node.type === 'fence' || node.type === 'fence' ||
node.type === 'column' || node.type === 'column' ||
node.type === 'slab' || node.type === 'slab' ||
node.type === 'ceiling' node.type === 'ceiling' ||
node.type === 'shelf'
) { ) {
const compatible = hasActivePaintMaterial(activePaintMaterial) const compatible = hasActivePaintMaterial(activePaintMaterial)
@@ -914,7 +968,7 @@ export const SelectionManager = () => {
.updateNode( .updateNode(
node.id as AnyNodeId, node.id as AnyNodeId,
buildSingleSurfaceMaterialPatch< buildSingleSurfaceMaterialPatch<
FenceNode | ColumnNode | SlabNode | CeilingNode FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
>(activePaintMaterial.material, activePaintMaterial.materialPreset), >(activePaintMaterial.material, activePaintMaterial.materialPreset),
) )
} }
@@ -922,7 +976,7 @@ export const SelectionManager = () => {
preview: compatible preview: compatible
? () => ? () =>
applySingleSurfacePaintPreview( applySingleSurfacePaintPreview(
node as FenceNode | ColumnNode | SlabNode | CeilingNode, node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
activePaintMaterial, activePaintMaterial,
) )
: () => previewCursor('not-allowed'), : () => previewCursor('not-allowed'),
@@ -1017,14 +1071,21 @@ export const SelectionManager = () => {
'zone', 'zone',
] as const ] 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}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:leave` as any, onLeave as any)
emitter.on(`${type}:click` as any, onClick as any) emitter.on(`${type}:click` as any, onClick as any)
} }
return () => { return () => {
for (const type of allTypes) { for (const type of subscribedKinds) {
emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:leave` as any, onLeave as any)
emitter.off(`${type}:click` as any, onClick as any) emitter.off(`${type}:click` as any, onClick as any)
@@ -1151,7 +1212,10 @@ export const SelectionManager = () => {
} }
if ( 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 nodeToSelect.type === node.type
) { ) {
setSelectedMaterialTargetForNode(nodeToSelect, 'surface') setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
@@ -1187,7 +1251,14 @@ export const SelectionManager = () => {
'window', 'window',
'door', '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) emitter.on(`${type}:click` as any, onClick as any)
}) })
@@ -1208,7 +1279,7 @@ export const SelectionManager = () => {
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
return () => { return () => {
allTypes.forEach((type) => { subscribedKinds.forEach((type) => {
emitter.off(`${type}:click` as any, onClick as any) emitter.off(`${type}:click` as any, onClick as any)
}) })
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
@@ -1340,14 +1411,19 @@ export const SelectionManager = () => {
'zone', 'zone',
'site', '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}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:leave` as any, onLeave as any)
emitter.on(`${type}:double-click` as any, onDoubleClick as any) emitter.on(`${type}:double-click` as any, onDoubleClick as any)
}) })
return () => { return () => {
allTypes.forEach((type) => { subscribedKinds.forEach((type) => {
emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:leave` as any, onLeave as any)
emitter.off(`${type}:double-click` as any, onDoubleClick as any) emitter.off(`${type}:double-click` as any, onDoubleClick as any)
@@ -1414,14 +1490,19 @@ export const SelectionManager = () => {
'zone', 'zone',
] as const ] 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}:click` as any, onClick as any)
emitter.on(`${type}:enter` as any, onEnter as any) emitter.on(`${type}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:leave` as any, onLeave as any)
} }
return () => { return () => {
for (const type of allTypes) { for (const type of subscribedKinds) {
emitter.off(`${type}:click` as any, onClick as any) emitter.off(`${type}:click` as any, onClick as any)
emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:leave` as any, onLeave as any)
@@ -207,7 +207,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const restoreNodeVisibility = (() => { const restoreNodeVisibility = (() => {
const saved = new Map<THREE.Object3D, boolean>() const saved = new Map<THREE.Object3D, boolean>()
for (const type of ['scan', 'guide'] as const) { for (const type of ['scan', 'guide'] as const) {
const ids = sceneRegistry.byType[type] const ids = sceneRegistry.byType[type]!
ids.forEach((id) => { ids.forEach((id) => {
const node = sceneRegistry.nodes.get(id) const node = sceneRegistry.nodes.get(id)
if (node) { if (node) {
@@ -179,12 +179,11 @@ export function useFloorplanBackgroundPlacement({
return true return true
} }
if (isFloorplanGridInteractionActive) { // Slab / zone polygon build — local draft state + grid emit.
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event) // Must run BEFORE the `isFloorplanGridInteractionActive` catch-all
setCursorPoint(snappedPoint) // (since slab is registry-driven, the catch-all would otherwise
return true // swallow the click and skip local draft state updates — leaving
} // the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) { if (isPolygonBuildActive) {
const snappedPoint = snapPolygonDraftPoint({ const snappedPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
@@ -192,6 +191,13 @@ export function useFloorplanBackgroundPlacement({
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, 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) { if (isZoneBuildActive) {
handleZonePlacementPoint(snappedPoint) handleZonePlacementPoint(snappedPoint)
} else { } else {
@@ -200,10 +206,12 @@ export function useFloorplanBackgroundPlacement({
return true return true
} }
if (!isWallBuildActive) { // Wall placement — local draft state + grid emit. Same reasoning
return false // 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({ const snappedPoint = snapWallDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
@@ -211,8 +219,21 @@ export function useFloorplanBackgroundPlacement({
angleSnap: Boolean(draftStart) && !shiftPressed, angleSnap: Boolean(draftStart) && !shiftPressed,
}) })
emitFloorplanGridEvent('click', snappedPoint, event)
handleWallPlacementPoint(snappedPoint) handleWallPlacementPoint(snappedPoint)
return true 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, activePolygonDraftPoints,
@@ -46,7 +46,7 @@ export const CeilingSystem = () => {
} }
} }
const ceilings = sceneRegistry.byType.ceiling const ceilings = sceneRegistry.byType.ceiling!
ceilings.forEach((ceiling) => { ceilings.forEach((ceiling) => {
const mesh = sceneRegistry.nodes.get(ceiling) const mesh = sceneRegistry.nodes.get(ceiling)
if (mesh) { if (mesh) {
@@ -8,7 +8,6 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' 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 { import type {
AnyNodeId, AnyNodeId,
BuildingNode, BuildingNode,
CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode, ElevatorNode,
FenceNode,
ItemNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
SlabNode,
SpawnNode, SpawnNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
WallNode,
WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { Vector3 } from 'three' import { nodeRegistry } from '@pascal-app/core'
import { sfxEmitter } from '../../../lib/sfx-bus' import { Suspense } from 'react'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool' 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 { 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 { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveSlabTool } from '../slab/move-slab-tool' import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
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}</>
}
/**
* 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<{ export const MoveTool: React.FC<{
onNodeMoved?: (nodeId: AnyNodeId) => void onNodeMoved?: (nodeId: AnyNodeId) => void
onSpawnMoved?: (nodeId: SpawnNode['id']) => void onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onNodeMoved, onSpawnMoved }) => { }> = ({ onNodeMoved }) => {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null 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') if (movingNode.type === 'building')
return <MoveBuildingContent node={movingNode as BuildingNode} /> return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'elevator') if (movingNode.type === 'elevator')
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} /> 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') if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} /> 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') if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} /> return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} /> return null
} }
@@ -6,12 +6,15 @@ import type {
GridEvent, GridEvent,
ItemEvent, ItemEvent,
ItemNode, ItemNode,
ShelfEvent,
ShelfNode,
WallEvent, WallEvent,
WallNode, WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
getScaledDimensions, getScaledDimensions,
isLowProfileItemSurface, isLowProfileItemSurface,
nodeRegistry,
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 // VALIDATION
// ============================================================================ // ============================================================================
@@ -603,6 +756,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
return ctx.state.surfaceItemId !== null 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 attachTo = ctx.draftItem.asset.attachTo
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
@@ -12,7 +12,7 @@ import type { Vector3 } from 'three'
// PLACEMENT STATE // 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. * Tracks which surface the draft item is currently on.
@@ -23,6 +23,13 @@ export interface PlacementState {
wallId: string | null wallId: string | null
ceilingId: string | null ceilingId: string | null
surfaceItemId: 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 (!draftRef.current) return
if (adoptedRef.current && originalStateRef.current) { 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 original = originalStateRef.current
const id = draftRef.current.id 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, { useScene.getState().updateNode(id, {
position: original.position, position: original.position,
@@ -7,6 +7,7 @@ import {
getScaledDimensions, getScaledDimensions,
type ItemEvent, type ItemEvent,
resolveLevelId, resolveLevelId,
type ShelfEvent,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveTransforms, useLiveTransforms,
@@ -41,6 +42,7 @@ import {
checkCanPlace, checkCanPlace,
floorStrategy, floorStrategy,
itemSurfaceStrategy, itemSurfaceStrategy,
shelfSurfaceStrategy,
wallStrategy, wallStrategy,
} from './placement-strategies' } from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types' 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 gridPosition = useRef(new Vector3(0, 0, 0))
const lastRawPos = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0))
const placementState = useRef<PlacementState>( 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 shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null) 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) const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
// Store config callbacks in refs to avoid re-running effect when they change // 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, wallId: null,
ceilingId: null, ceilingId: null,
surfaceItemId: null, surfaceItemId: null,
shelfId: null,
} }
if (!asset.attachTo && placementState.current.surface === 'floor') { if (!asset.attachTo && placementState.current.surface === 'floor') {
gridPosition.current.y = 0 gridPosition.current.y = 0
@@ -552,6 +569,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
configRef.current.initDraft(gridPosition.current) configRef.current.initDraft(gridPosition.current)
} }
has3DPointerDrivenMoveRef.current = true
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2]) lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
const result = floorStrategy.move(getContext(), event) const result = floorStrategy.move(getContext(), event)
if (!result) return if (!result) return
@@ -609,6 +627,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Wall Handlers ---- // ---- Wall Handlers ----
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
has3DPointerDrivenMoveRef.current = true
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const result = wallStrategy.enter( const result = wallStrategy.enter(
getContext(), getContext(),
@@ -634,6 +653,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
has3DPointerDrivenMoveRef.current = true
const ctx = getContext() const ctx = getContext()
if (ctx.state.surface !== 'wall') { if (ctx.state.surface !== 'wall') {
@@ -824,6 +844,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const onItemEnter = (event: ItemEvent) => { const onItemEnter = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return if (event.node.id === draftNode.current?.id) return
has3DPointerDrivenMoveRef.current = true
const result = itemSurfaceStrategy.enter(getContext(), event) const result = itemSurfaceStrategy.enter(getContext(), event)
if (!result) return if (!result) return
@@ -840,6 +861,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const onItemMove = (event: ItemEvent) => { const onItemMove = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return if (event.node.id === draftNode.current?.id) return
has3DPointerDrivenMoveRef.current = true
const ctx = getContext() const ctx = getContext()
if (ctx.state.surface !== 'item-surface') { if (ctx.state.surface !== 'item-surface') {
@@ -923,7 +945,102 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onItemClick = (event: ItemEvent) => { 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) const result = itemSurfaceStrategy.click(getContext(), event)
if (!result) return if (!result) return
@@ -948,6 +1065,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Ceiling Handlers ---- // ---- Ceiling Handlers ----
const onCeilingEnter = (event: CeilingEvent) => { const onCeilingEnter = (event: CeilingEvent) => {
has3DPointerDrivenMoveRef.current = true
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes) const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!result) return if (!result) return
@@ -967,6 +1085,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onCeilingMove = (event: CeilingEvent) => { const onCeilingMove = (event: CeilingEvent) => {
has3DPointerDrivenMoveRef.current = true
if (!draftNode.current && placementState.current.surface === 'ceiling') { if (!draftNode.current && placementState.current.surface === 'ceiling') {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, 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 ---- // ---- Keyboard rotation ----
const ROTATION_STEP = Math.PI / 2 const ROTATION_STEP = Math.PI / 2
@@ -1239,6 +1452,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:move', onCeilingMove)
emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:click', onCeilingClick)
emitter.on('ceiling:leave', onCeilingLeave) 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 () => { return () => {
tearingDown = true tearingDown = true
@@ -1263,6 +1480,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:move', onCeilingMove)
emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:click', onCeilingClick)
emitter.off('ceiling:leave', onCeilingLeave) 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) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
@@ -1297,6 +1518,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useFrame((_, delta) => { useFrame((_, delta) => {
if (!asset) return if (!asset) return
if (!draftNode.current) 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) const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return 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 { import {
type AnyNode,
emitter, emitter,
type GridEvent, type GridEvent,
type LevelNode, type LevelNode,
@@ -2,55 +2,51 @@ import {
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
type CeilingNode, type CeilingNode,
nodeRegistry,
type SlabNode, type SlabNode,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor' 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 { ColumnTool } from './column/column-tool'
import { DoorTool } from './door/door-tool'
import { ElevatorTool } from './elevator/elevator-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 { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool' import { RoofTool } from './roof/roof-tool'
import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
import { SiteBoundaryEditor } from './site/site-boundary-editor' 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 { 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 { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
import { ZoneTool } from './zone/zone-tool' 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>>> = { const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: { site: {
'property-line': SiteBoundaryEditor, 'property-line': SiteBoundaryEditor,
}, },
structure: { structure: {
wall: WallTool,
fence: FenceTool,
slab: SlabTool,
ceiling: CeilingTool,
roof: RoofTool, roof: RoofTool,
stair: StairTool, stair: StairTool,
door: DoorTool,
item: ItemTool,
zone: ZoneTool, zone: ZoneTool,
window: WindowTool,
},
furnish: {
item: ItemTool,
}, },
furnish: {},
} }
export const ToolManager: React.FC = () => { export const ToolManager: React.FC = () => {
@@ -127,7 +123,12 @@ export const ToolManager: React.FC = () => {
// Show build tools when in build mode // Show build tools when in build mode
const showBuildTool = mode === 'build' && tool !== null 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) => { const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
setSelection({ selectedIds: [nodeId] }) setSelection({ selectedIds: [nodeId] })
} }
@@ -154,44 +155,114 @@ export const ToolManager: React.FC = () => {
rotation={buildingRotation as [number, number, number]} rotation={buildingRotation as [number, number, number]}
> >
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />} {showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />} {showSlabBoundaryEditor &&
{showSlabHoleEditor && selectedSlabId && editingHole && ( selectedSlabId &&
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} /> (() => {
)} const Registry = getRegistryAffordanceTool('slab', 'boundary-edit')
{showCeilingBoundaryEditor && selectedCeilingId && ( return Registry ? (
<CeilingBoundaryEditor ceilingId={selectedCeilingId} /> <Suspense fallback={null}>
)} <Registry slabId={selectedSlabId} />
{showCeilingHoleEditor && selectedCeilingId && editingHole && ( </Suspense>
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} /> ) : null
)} })()}
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />} {showSlabHoleEditor &&
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />} selectedSlabId &&
{curvingWall && <CurveWallTool node={curvingWall} />} editingHole &&
{curvingFence && <CurveFenceTool node={curvingFence} />} (() => {
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' && ( {movingNode && movingNode.type !== 'building' && (
<MoveTool <MoveTool
onNodeMoved={handlePlacedNodeSelected} onNodeMoved={handlePlacedNodeSelected}
onSpawnMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected}
/> />
)} )}
{!movingNode && showBuildTool && tool === 'spawn' && ( {/* Registry-first: when the active tool's kind has a registered
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} /> 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} /> <ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
)} )}
{!movingNode && showBuildTool && tool === 'elevator' && ( {!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && (
<ElevatorTool <ElevatorTool
buildingId={buildingId as BuildingNode['id'] | null} buildingId={buildingId as BuildingNode['id'] | null}
levelId={activeLevelId ?? null} levelId={activeLevelId ?? null}
onPlaced={handlePlacedElevatorSelected} onPlaced={handlePlacedElevatorSelected}
/> />
)} )}
{!movingNode && {!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? (
BuildToolComponent &&
tool !== 'spawn' &&
tool !== 'column' &&
tool !== 'elevator' ? (
<BuildToolComponent /> <BuildToolComponent />
) : null} ) : null}
</group> </group>
@@ -33,6 +33,7 @@ export const tools: ToolConfig[] = [
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' }, { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
] ]
export function StructureTools() { 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' 'use client'
import { nodeRegistry } from '@pascal-app/core'
import { useIsMobile } from '../../../hooks/use-mobile' import { useIsMobile } from '../../../hooks/use-mobile'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper' import { BuildingHelper } from './building-helper'
import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper' import { ItemHelper } from './item-helper'
import { RegisteredToolHelper } from './registered-tool-helper'
import { RoofHelper } from './roof-helper' import { RoofHelper } from './roof-helper'
import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper'
export function HelperManager() { export function HelperManager() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
@@ -27,19 +26,19 @@ export function HelperManager() {
return null return null
} }
// Show appropriate helper based on current tool // Registry-first: kinds with `def.toolHints` render through the generic
switch (tool) { // `RegisteredToolHelper`. Today that covers ceiling / door / fence /
case 'wall': // item / shelf / slab / spawn / wall / window.
return <WallHelper /> if (tool) {
case 'item': const def = nodeRegistry.get(tool)
return <ItemHelper /> if (def?.toolHints && def.toolHints.length > 0) {
case 'slab': return <RegisteredToolHelper hints={def.toolHints} />
return <SlabHelper /> }
case 'ceiling': }
return <CeilingHelper />
case 'roof': // Legacy fallback — only `roof` remains because it hasn't migrated to
return <RoofHelper /> // `def.tool` / `def.toolHints` yet (no Stage D port). When roof
default: // migrates, this switch deletes outright.
if (tool === 'roof') return <RoofHelper />
return null 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 { useIsMobile } from '../../../hooks/use-mobile'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' 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 { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar' import { MobileSelectionBar } from './mobile-selection-bar'
import { getNodeDisplay } from './node-display' import { getNodeDisplay } from './node-display'
import { PaintPanel } from './paint-panel' import { PaintPanel } from './paint-panel'
import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel' 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 = type MovableNode =
| ItemNode | ItemNode
@@ -83,38 +70,15 @@ function isMovableNode(node: AnyNode | null): node is MovableNode {
function panelForType(type: string | null) { function panelForType(type: string | null) {
if (!type) return null if (!type) return null
switch (type) { // Every kind now renders through `<ParametricInspector>`, which either
case 'item': // composes auto-derived editors from `parametrics.groups` or lazy-
return <ItemPanel /> // loads the kind-owned panel via `parametrics.customPanel`. The
case 'roof': // hardcoded switch is gone — all per-kind panel layout lives in
return <RoofPanel /> // `nodes/src/<kind>/panel.tsx`. The `type` arg is preserved for
case 'roof-segment': // future cases where we might want a non-registry fallback (e.g.
return <RoofSegmentPanel /> // reference scale, paint mode); leave the function shape intact.
case 'stair': void type
return <StairPanel /> return <ParametricInspector />
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
}
} }
function MobilePanelLayer({ function MobilePanelLayer({
@@ -7,7 +7,11 @@ import { cn } from '../../../lib/utils'
interface PanelWrapperProps { interface PanelWrapperProps {
title: string 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 onClose?: () => void
onReset?: () => void onReset?: () => void
onBack?: () => void onBack?: () => void
@@ -58,9 +62,18 @@ export function PanelWrapper({
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</button> </button>
)} )}
{icon && ( {icon &&
<Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} /> (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"> <h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
{title} {title}
</h2> </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++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1] const pi = polygon[i]
area -= polygon[j]?.[0] * polygon[i]?.[1] const pj = polygon[j]
if (!(pi && pj)) continue
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
@@ -38,7 +38,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions nodeId={node.id} />}
depth={depth} depth={depth}
expanded={false} expanded={false}
hasChildren={false} hasChildren={false}
@@ -53,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
<InlineRenameInput <InlineRenameInput
defaultName="Fence" defaultName="Fence"
isEditing={isEditing} isEditing={isEditing}
node={node} nodeId={node.id}
onStartEditing={() => setIsEditing(true)} onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)} 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++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1] const pi = polygon[i]
area -= polygon[j]?.[0] * polygon[i]?.[1] const pj = polygon[j]
if (!(pi && pj)) continue
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
@@ -63,6 +63,7 @@ import { FenceTreeNode } from './fence-tree-node'
import { ItemTreeNode } from './item-tree-node' import { ItemTreeNode } from './item-tree-node'
import { LevelTreeNode } from './level-tree-node' import { LevelTreeNode } from './level-tree-node'
import { RoofTreeNode } from './roof-tree-node' import { RoofTreeNode } from './roof-tree-node'
import { ShelfTreeNode } from './shelf-tree-node'
import { SlabTreeNode } from './slab-tree-node' import { SlabTreeNode } from './slab-tree-node'
import { SpawnTreeNode } from './spawn-tree-node' import { SpawnTreeNode } from './spawn-tree-node'
import { StairTreeNode } from './stair-tree-node' import { StairTreeNode } from './stair-tree-node'
@@ -76,47 +77,61 @@ interface TreeNodeProps {
isLast?: boolean 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) { export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
const nodeType = useScene((state) => state.nodes[nodeId]?.type) const nodeType = useScene((state) => state.nodes[nodeId]?.type)
if (!nodeType) return null if (!nodeType) return null
const Component = treeNodeByType[nodeType]
switch (nodeType) { if (!Component) return null
case 'building': return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
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
}
}) })
interface TreeNodeWrapperProps { interface TreeNodeWrapperProps {
@@ -12,7 +12,7 @@ export const ViewerZoneSystem = () => {
const structureLayer = useEditor.getState().structureLayer const structureLayer = useEditor.getState().structureLayer
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
sceneRegistry.byType.zone.forEach((id) => { sceneRegistry.byType.zone!.forEach((id) => {
const obj = sceneRegistry.nodes.get(id) const obj = sceneRegistry.nodes.get(id)
if (!obj) return 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, type SnapshotCameraData,
ThumbnailGenerator, ThumbnailGenerator,
} from './components/editor/thumbnail-generator' } 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 { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
export { useCommandPalette } from './components/ui/command-palette' 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 { SliderControl } from './components/ui/controls/slider-control'
export { ToggleControl } from './components/ui/controls/toggle-control'
export { FloatingLevelSelector } from './components/ui/floating-level-selector' export { FloatingLevelSelector } from './components/ui/floating-level-selector'
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items' 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 { PALETTE_COLORS } from './components/ui/primitives/color-dot'
export { useSidebarStore } from './components/ui/primitives/sidebar' export { useSidebarStore } from './components/ui/primitives/sidebar'
export { Slider } from './components/ui/primitives/slider' export { Slider } from './components/ui/primitives/slider'
@@ -24,14 +121,62 @@ export {
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel' export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export type { SidebarTab } from './components/ui/sidebar/tab-bar' export type { SidebarTab } from './components/ui/sidebar/tab-bar'
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context' 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' 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 type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus' 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 { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry' 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 { default as useEditor } from './store/use-editor'
export { export {
type PaletteView, type PaletteView,
+54 -42
View File
@@ -33,50 +33,62 @@ function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
return true return true
} }
function stripMaterials(node: AnyNode): AnyNode { /**
const next = { ...node } as Record<string, unknown> * Material field keys per kind, used by the `structure` duplicate preset
* to strip materials from the cloned subtree. Lookup table replaces the
switch (node.type) { * legacy per-kind switch — the Phase 6 grep gate flagged `case '<kind>':`
case 'wall': * in this file as the remaining per-kind dispatch outside the registry.
delete next.material *
delete next.materialPreset * Future: move this to a `capabilities.materialFields` declaration on
delete next.interiorMaterial * each kind's `NodeDefinition` so adding a new kind with materials is a
delete next.interiorMaterialPreset * registry-only edit. Today the registry doesn't surface material fields
delete next.exteriorMaterial * in a uniform way (each kind's panel reads / writes them directly), so
delete next.exteriorMaterialPreset * this map mirrors the legacy behavior 1:1.
break */
case 'slab': const MATERIAL_FIELDS_BY_KIND: Record<string, ReadonlyArray<string>> = {
case 'ceiling': wall: [
case 'fence': 'material',
case 'roof-segment': 'materialPreset',
case 'stair-segment': 'interiorMaterial',
case 'window': 'interiorMaterialPreset',
case 'door': 'exteriorMaterial',
delete next.material 'exteriorMaterialPreset',
delete next.materialPreset ],
break slab: ['material', 'materialPreset'],
case 'roof': ceiling: ['material', 'materialPreset'],
delete next.material fence: ['material', 'materialPreset'],
delete next.materialPreset shelf: ['material', 'materialPreset'],
delete next.topMaterial 'roof-segment': ['material', 'materialPreset'],
delete next.topMaterialPreset 'stair-segment': ['material', 'materialPreset'],
delete next.edgeMaterial window: ['material', 'materialPreset'],
delete next.edgeMaterialPreset door: ['material', 'materialPreset'],
delete next.wallMaterial roof: [
delete next.wallMaterialPreset 'material',
break 'materialPreset',
case 'stair': 'topMaterial',
delete next.material 'topMaterialPreset',
delete next.materialPreset 'edgeMaterial',
delete next.railingMaterial 'edgeMaterialPreset',
delete next.railingMaterialPreset 'wallMaterial',
delete next.treadMaterial 'wallMaterialPreset',
delete next.treadMaterialPreset ],
delete next.sideMaterial stair: [
delete next.sideMaterialPreset 'material',
break '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 return next as AnyNode
} }
+9 -3
View File
@@ -13,6 +13,7 @@ import {
type MaterialTarget, type MaterialTarget,
type RoofNode, type RoofNode,
type RoofSurfaceMaterialRole, type RoofSurfaceMaterialRole,
type ShelfNode,
type SlabNode, type SlabNode,
type StairNode, type StairNode,
type StairSurfaceMaterialRole, type StairSurfaceMaterialRole,
@@ -22,7 +23,7 @@ import {
export type PaintableMaterialTarget = Extract< export type PaintableMaterialTarget = Extract<
MaterialTarget, MaterialTarget,
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' 'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' | 'shelf'
> >
export type SingleSurfaceMaterialRole = 'surface' export type SingleSurfaceMaterialRole = 'surface'
@@ -133,7 +134,7 @@ export function buildStairSurfaceMaterialPatch(
} }
export function buildSingleSurfaceMaterialPatch< export function buildSingleSurfaceMaterialPatch<
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode, TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> { >(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
return { return {
material, material,
@@ -222,7 +223,8 @@ export function resolveActivePaintMaterialFromSelection(params: {
(selectedNode.type === 'fence' || (selectedNode.type === 'fence' ||
selectedNode.type === 'column' || selectedNode.type === 'column' ||
selectedNode.type === 'slab' || selectedNode.type === 'slab' ||
selectedNode.type === 'ceiling') && selectedNode.type === 'ceiling' ||
selectedNode.type === 'shelf') &&
selectedMaterialTarget.role === 'surface' selectedMaterialTarget.role === 'surface'
) { ) {
const target = selectedNode.type const target = selectedNode.type
@@ -280,5 +282,9 @@ export function resolvePaintTargetFromSelection(params: {
return 'ceiling' return 'ceiling'
} }
if (selectedNode.type === 'shelf') {
return 'shelf'
}
return null return null
} }
+6 -2
View File
@@ -283,7 +283,11 @@ export function syncEditorSelectionFromCurrentScene() {
if (shouldRestoreEditorUiState) { if (shouldRestoreEditorUiState) {
if (restoredSelection) { 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( useEditor.setState(
restoredEditorUiState.phase === 'site' restoredEditorUiState.phase === 'site'
? (selectionDrivenEditorUiState ?? restoredEditorUiState) ? (selectionDrivenEditorUiState ?? restoredEditorUiState)
@@ -305,7 +309,7 @@ export function syncEditorSelectionFromCurrentScene() {
} }
if (restoredSelection) { if (restoredSelection) {
useViewer.getState().setSelection(restoredSelection) useViewer.getState().setSelection(restoredSelection as never)
if (selectionDrivenEditorUiState) { if (selectionDrivenEditorUiState) {
useEditor.setState(selectionDrivenEditorUiState) useEditor.setState(selectionDrivenEditorUiState)
} }
+1
View File
@@ -65,6 +65,7 @@ export type StructureTool =
| 'spawn' | 'spawn'
| 'window' | 'window'
| 'door' | 'door'
| 'shelf'
// Furnish mode tools (items and decoration) // Furnish mode tools (items and decoration)
export type FurnishTool = 'item' 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