Merge pull request #314 from pascalorg/feat/node-registry-primitives
Node registry & plugin-ready architecture (Phase 0-5)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import '../lib/bootstrap'
|
||||||
import {
|
import {
|
||||||
applySceneGraphToEditor,
|
applySceneGraphToEditor,
|
||||||
Editor,
|
Editor,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.1–0.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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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':
|
|
||||||
return <RoofHelper />
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback — only `roof` remains because it hasn't migrated to
|
||||||
|
// `def.tool` / `def.toolHints` yet (no Stage D port). When roof
|
||||||
|
// migrates, this switch deletes outright.
|
||||||
|
if (tool === 'roof') return <RoofHelper />
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ToolHint } from '@pascal-app/core'
|
||||||
|
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic helper panel rendered from `def.toolHints` data. Matches the
|
||||||
|
* visual styling of the hand-written `<WallHelper>` / `<ItemHelper>` /
|
||||||
|
* etc. so registry-driven kinds get a consistent look without each kind
|
||||||
|
* writing its own component.
|
||||||
|
*
|
||||||
|
* Drops the need for per-kind helper files entirely — kinds declare
|
||||||
|
* their hints as static data in their `NodeDefinition`.
|
||||||
|
*/
|
||||||
|
export function RegisteredToolHelper({ hints }: { hints: ToolHint[] }) {
|
||||||
|
if (hints.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||||
|
{hints.map((hint) => (
|
||||||
|
<div className="flex items-center gap-2 text-sm" key={`${hint.key}:${hint.label}`}>
|
||||||
|
<ShortcutToken value={hint.key} />
|
||||||
|
<span className="text-muted-foreground">{hint.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
|
||||||
|
|
||||||
export function SlabHelper() {
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<ShortcutToken value="Left click" />
|
|
||||||
<span className="text-muted-foreground">Add point</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<ShortcutToken value="Shift" />
|
|
||||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<ShortcutToken value="Esc" />
|
|
||||||
<span className="text-muted-foreground">Cancel</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
|
||||||
|
|
||||||
export function WallHelper() {
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<ShortcutToken value="Left click" />
|
|
||||||
<span className="text-muted-foreground">Set wall start / end</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<ShortcutToken value="Shift" />
|
|
||||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<ShortcutToken value="Esc" />
|
|
||||||
<span className="text-muted-foreground">Cancel</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import {
|
|
||||||
type AnyNode,
|
|
||||||
type AnyNodeId,
|
|
||||||
type FenceNode,
|
|
||||||
getClampedWallCurveOffset,
|
|
||||||
getMaxWallCurveOffset,
|
|
||||||
getWallCurveLength,
|
|
||||||
type MaterialSchema,
|
|
||||||
normalizeWallCurveOffset,
|
|
||||||
useScene,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { Move, Spline } from 'lucide-react'
|
|
||||||
import { useCallback } from 'react'
|
|
||||||
|
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
||||||
import useEditor from '../../../store/use-editor'
|
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
|
||||||
import { SegmentedControl } from '../controls/segmented-control'
|
|
||||||
import { SliderControl } from '../controls/slider-control'
|
|
||||||
import { ToggleControl } from '../controls/toggle-control'
|
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
|
||||||
|
|
||||||
type FenceStyleValue = 'slat' | 'rail' | 'privacy'
|
|
||||||
type FenceBaseStyleValue = 'grounded' | 'floating'
|
|
||||||
|
|
||||||
const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyleValue }[] = [
|
|
||||||
{ label: 'Slat', value: 'slat' },
|
|
||||||
{ label: 'Rail', value: 'rail' },
|
|
||||||
{ label: 'Privacy', value: 'privacy' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyleValue }[] = [
|
|
||||||
{ label: 'Grounded', value: 'grounded' },
|
|
||||||
{ label: 'Floating', value: 'floating' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function FencePanel() {
|
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
||||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
||||||
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
|
||||||
|
|
||||||
const node = useScene((s) =>
|
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
|
||||||
(updates: Partial<FenceNode>) => {
|
|
||||||
if (!selectedId) return
|
|
||||||
updateNode(selectedId as AnyNode['id'], updates)
|
|
||||||
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
|
|
||||||
},
|
|
||||||
[selectedId, updateNode],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleUpdateLength = useCallback(
|
|
||||||
(newLength: number) => {
|
|
||||||
if (!node || newLength <= 0) return
|
|
||||||
|
|
||||||
const dx = node.end[0] - node.start[0]
|
|
||||||
const dz = node.end[1] - node.start[1]
|
|
||||||
const currentLength = Math.sqrt(dx * dx + dz * dz)
|
|
||||||
if (currentLength === 0) return
|
|
||||||
|
|
||||||
const dirX = dx / currentLength
|
|
||||||
const dirZ = dz / currentLength
|
|
||||||
const newEnd: [number, number] = [
|
|
||||||
node.start[0] + dirX * newLength,
|
|
||||||
node.start[1] + dirZ * newLength,
|
|
||||||
]
|
|
||||||
|
|
||||||
handleUpdate({ end: newEnd })
|
|
||||||
},
|
|
||||||
[node, handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setSelection({ selectedIds: [] })
|
|
||||||
}, [setSelection])
|
|
||||||
|
|
||||||
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
|
||||||
|
|
||||||
const length = getWallCurveLength(node)
|
|
||||||
const curveOffset = getClampedWallCurveOffset(node)
|
|
||||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PanelWrapper
|
|
||||||
icon="/icons/build.png"
|
|
||||||
onClose={handleClose}
|
|
||||||
title={node.name || 'Fence'}
|
|
||||||
width={300}
|
|
||||||
>
|
|
||||||
<PanelSection title="Style">
|
|
||||||
<SegmentedControl
|
|
||||||
onChange={(value) => handleUpdate({ style: value })}
|
|
||||||
options={FENCE_STYLE_OPTIONS}
|
|
||||||
value={node.style}
|
|
||||||
/>
|
|
||||||
<SegmentedControl
|
|
||||||
className="mt-2"
|
|
||||||
onChange={(value) => handleUpdate({ baseStyle: value })}
|
|
||||||
options={FENCE_BASE_STYLE_OPTIONS}
|
|
||||||
value={node.baseStyle}
|
|
||||||
/>
|
|
||||||
<ToggleControl
|
|
||||||
checked={node.showInfill ?? true}
|
|
||||||
className="mt-2"
|
|
||||||
label="Fence Infill"
|
|
||||||
onChange={(checked) => handleUpdate({ showInfill: checked })}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
|
|
||||||
<PanelSection title="Dimensions">
|
|
||||||
<SliderControl
|
|
||||||
label="Length"
|
|
||||||
max={50}
|
|
||||||
min={0.1}
|
|
||||||
onChange={handleUpdateLength}
|
|
||||||
precision={2}
|
|
||||||
step={0.01}
|
|
||||||
unit="m"
|
|
||||||
value={length}
|
|
||||||
/>
|
|
||||||
<SliderControl
|
|
||||||
label="Curve"
|
|
||||||
max={Math.max(0.01, maxCurveOffset)}
|
|
||||||
min={-Math.max(0.01, maxCurveOffset)}
|
|
||||||
onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
|
|
||||||
precision={2}
|
|
||||||
step={0.1}
|
|
||||||
unit="m"
|
|
||||||
value={Math.round(curveOffset * 100) / 100}
|
|
||||||
/>
|
|
||||||
<SliderControl
|
|
||||||
label="Height"
|
|
||||||
max={4}
|
|
||||||
min={0.4}
|
|
||||||
onChange={(value) => handleUpdate({ height: Math.max(0.4, value) })}
|
|
||||||
precision={2}
|
|
||||||
step={0.05}
|
|
||||||
unit="m"
|
|
||||||
value={node.height}
|
|
||||||
/>
|
|
||||||
<SliderControl
|
|
||||||
label="Thickness"
|
|
||||||
max={0.5}
|
|
||||||
min={0.03}
|
|
||||||
onChange={(value) => handleUpdate({ thickness: Math.max(0.03, value) })}
|
|
||||||
precision={3}
|
|
||||||
step={0.005}
|
|
||||||
unit="m"
|
|
||||||
value={node.thickness}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
|
|
||||||
<PanelSection title="Structure">
|
|
||||||
<SliderControl
|
|
||||||
label="Base Height"
|
|
||||||
max={1}
|
|
||||||
min={0.04}
|
|
||||||
onChange={(value) => handleUpdate({ baseHeight: Math.max(0.04, value) })}
|
|
||||||
precision={3}
|
|
||||||
step={0.01}
|
|
||||||
unit="m"
|
|
||||||
value={node.baseHeight}
|
|
||||||
/>
|
|
||||||
<SliderControl
|
|
||||||
label="Top Rail"
|
|
||||||
max={0.25}
|
|
||||||
min={0.01}
|
|
||||||
onChange={(value) => handleUpdate({ topRailHeight: Math.max(0.01, value) })}
|
|
||||||
precision={3}
|
|
||||||
step={0.005}
|
|
||||||
unit="m"
|
|
||||||
value={node.topRailHeight}
|
|
||||||
/>
|
|
||||||
<SliderControl
|
|
||||||
label="Post Spacing"
|
|
||||||
max={5}
|
|
||||||
min={0.2}
|
|
||||||
onChange={(value) => handleUpdate({ postSpacing: Math.max(0.2, value) })}
|
|
||||||
precision={2}
|
|
||||||
step={0.05}
|
|
||||||
unit="m"
|
|
||||||
value={node.postSpacing}
|
|
||||||
/>
|
|
||||||
<SliderControl
|
|
||||||
label="Post Size"
|
|
||||||
max={0.4}
|
|
||||||
min={0.01}
|
|
||||||
onChange={(value) => handleUpdate({ postSize: Math.max(0.01, value) })}
|
|
||||||
precision={3}
|
|
||||||
step={0.005}
|
|
||||||
unit="m"
|
|
||||||
value={node.postSize}
|
|
||||||
/>
|
|
||||||
<SliderControl
|
|
||||||
label="Ground Clear"
|
|
||||||
max={0.6}
|
|
||||||
min={0}
|
|
||||||
onChange={(value) => handleUpdate({ groundClearance: Math.max(0, value) })}
|
|
||||||
precision={3}
|
|
||||||
step={0.005}
|
|
||||||
unit="m"
|
|
||||||
value={node.groundClearance}
|
|
||||||
/>
|
|
||||||
<SliderControl
|
|
||||||
label="Edge Inset"
|
|
||||||
max={0.25}
|
|
||||||
min={0.005}
|
|
||||||
onChange={(value) => handleUpdate({ edgeInset: Math.max(0.005, value) })}
|
|
||||||
precision={3}
|
|
||||||
step={0.005}
|
|
||||||
unit="m"
|
|
||||||
value={node.edgeInset}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -24,25 +24,12 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
import { 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 }
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -33,50 +33,62 @@ function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material field keys per kind, used by the `structure` duplicate preset
|
||||||
|
* to strip materials from the cloned subtree. Lookup table replaces the
|
||||||
|
* legacy per-kind switch — the Phase 6 grep gate flagged `case '<kind>':`
|
||||||
|
* in this file as the remaining per-kind dispatch outside the registry.
|
||||||
|
*
|
||||||
|
* Future: move this to a `capabilities.materialFields` declaration on
|
||||||
|
* each kind's `NodeDefinition` so adding a new kind with materials is a
|
||||||
|
* registry-only edit. Today the registry doesn't surface material fields
|
||||||
|
* in a uniform way (each kind's panel reads / writes them directly), so
|
||||||
|
* this map mirrors the legacy behavior 1:1.
|
||||||
|
*/
|
||||||
|
const MATERIAL_FIELDS_BY_KIND: Record<string, ReadonlyArray<string>> = {
|
||||||
|
wall: [
|
||||||
|
'material',
|
||||||
|
'materialPreset',
|
||||||
|
'interiorMaterial',
|
||||||
|
'interiorMaterialPreset',
|
||||||
|
'exteriorMaterial',
|
||||||
|
'exteriorMaterialPreset',
|
||||||
|
],
|
||||||
|
slab: ['material', 'materialPreset'],
|
||||||
|
ceiling: ['material', 'materialPreset'],
|
||||||
|
fence: ['material', 'materialPreset'],
|
||||||
|
shelf: ['material', 'materialPreset'],
|
||||||
|
'roof-segment': ['material', 'materialPreset'],
|
||||||
|
'stair-segment': ['material', 'materialPreset'],
|
||||||
|
window: ['material', 'materialPreset'],
|
||||||
|
door: ['material', 'materialPreset'],
|
||||||
|
roof: [
|
||||||
|
'material',
|
||||||
|
'materialPreset',
|
||||||
|
'topMaterial',
|
||||||
|
'topMaterialPreset',
|
||||||
|
'edgeMaterial',
|
||||||
|
'edgeMaterialPreset',
|
||||||
|
'wallMaterial',
|
||||||
|
'wallMaterialPreset',
|
||||||
|
],
|
||||||
|
stair: [
|
||||||
|
'material',
|
||||||
|
'materialPreset',
|
||||||
|
'railingMaterial',
|
||||||
|
'railingMaterialPreset',
|
||||||
|
'treadMaterial',
|
||||||
|
'treadMaterialPreset',
|
||||||
|
'sideMaterial',
|
||||||
|
'sideMaterialPreset',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
function stripMaterials(node: AnyNode): AnyNode {
|
function stripMaterials(node: AnyNode): AnyNode {
|
||||||
|
const fields = MATERIAL_FIELDS_BY_KIND[node.type]
|
||||||
|
if (!fields) return node
|
||||||
const next = { ...node } as Record<string, unknown>
|
const next = { ...node } as Record<string, unknown>
|
||||||
|
for (const field of fields) delete next[field]
|
||||||
switch (node.type) {
|
|
||||||
case 'wall':
|
|
||||||
delete next.material
|
|
||||||
delete next.materialPreset
|
|
||||||
delete next.interiorMaterial
|
|
||||||
delete next.interiorMaterialPreset
|
|
||||||
delete next.exteriorMaterial
|
|
||||||
delete next.exteriorMaterialPreset
|
|
||||||
break
|
|
||||||
case 'slab':
|
|
||||||
case 'ceiling':
|
|
||||||
case 'fence':
|
|
||||||
case 'roof-segment':
|
|
||||||
case 'stair-segment':
|
|
||||||
case 'window':
|
|
||||||
case 'door':
|
|
||||||
delete next.material
|
|
||||||
delete next.materialPreset
|
|
||||||
break
|
|
||||||
case 'roof':
|
|
||||||
delete next.material
|
|
||||||
delete next.materialPreset
|
|
||||||
delete next.topMaterial
|
|
||||||
delete next.topMaterialPreset
|
|
||||||
delete next.edgeMaterial
|
|
||||||
delete next.edgeMaterialPreset
|
|
||||||
delete next.wallMaterial
|
|
||||||
delete next.wallMaterialPreset
|
|
||||||
break
|
|
||||||
case 'stair':
|
|
||||||
delete next.material
|
|
||||||
delete next.materialPreset
|
|
||||||
delete next.railingMaterial
|
|
||||||
delete next.railingMaterialPreset
|
|
||||||
delete next.treadMaterial
|
|
||||||
delete next.treadMaterialPreset
|
|
||||||
delete next.sideMaterial
|
|
||||||
delete next.sideMaterialPreset
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return next as AnyNode
|
return next as AnyNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { buildingDefinition } from './definition'
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user