diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index b3606f76..22b655ad 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -1,5 +1,6 @@ 'use client' +import '../lib/bootstrap' import { applySceneGraphToEditor, Editor, diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts new file mode 100644 index 00000000..93d8336f --- /dev/null +++ b/apps/editor/lib/bootstrap.ts @@ -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 } }).process + ?.env + return env?.NODE_ENV !== 'production' +} + +export async function loadBuiltinNodes(): Promise { + 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() diff --git a/apps/editor/package.json b/apps/editor/package.json index f2c00a2e..b4ef4235 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -16,6 +16,7 @@ "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", + "@pascal-app/nodes": "*", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", diff --git a/apps/editor/public/icons/shelf.png b/apps/editor/public/icons/shelf.png new file mode 100644 index 00000000..84845573 Binary files /dev/null and b/apps/editor/public/icons/shelf.png differ diff --git a/biome.jsonc b/biome.jsonc index 26d8b8cf..a0b12108 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -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." + } + } + } + } + } + } } ] } diff --git a/bun.lock b/bun.lock index 64ed55c6..bd4fb1ba 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", + "@pascal-app/nodes": "*", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", @@ -182,6 +183,28 @@ "@pascal-app/core": "^0.8.0", }, }, + "packages/nodes": { + "name": "@pascal-app/nodes", + "version": "0.1.0", + "devDependencies": { + "@pascal-app/core": "^0.8.0", + "@pascal-app/viewer": "^0.8.0", + "@pascal/typescript-config": "*", + "@types/bun": "^1.3.0", + "@types/node": "^22.19.12", + "@types/react": "^19.2.2", + "@types/three": "^0.184.0", + "typescript": "6.0.2", + }, + "peerDependencies": { + "@pascal-app/core": "^0.8.0", + "@pascal-app/viewer": "^0.8.0", + "@react-three/drei": "^10", + "@react-three/fiber": "^9", + "react": "^18 || ^19", + "three": "^0.184", + }, + }, "packages/typescript-config": { "name": "@repo/typescript-config", "version": "0.0.0", @@ -451,6 +474,8 @@ "@pascal-app/mcp": ["@pascal-app/mcp@workspace:packages/mcp"], + "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], + "@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"], "@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"], @@ -1531,6 +1556,8 @@ "@pascal-app/mcp/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@pascal-app/nodes/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], + "@pascal-app/viewer/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1613,6 +1640,8 @@ "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@pascal-app/nodes/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@pascal-app/viewer/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@repo/ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/packages/core/package.json b/packages/core/package.json index 0b2322b3..0f43e81c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,11 @@ "import": "./dist/utils/clone-scene-graph.js", "default": "./dist/utils/clone-scene-graph.js" }, + "./registry": { + "types": "./dist/registry/index.d.ts", + "import": "./dist/registry/index.js", + "default": "./dist/registry/index.js" + }, "./schema": { "types": "./dist/schema/index.d.ts", "import": "./dist/schema/index.js", @@ -54,6 +59,8 @@ "scripts": { "build": "tsc --build", "dev": "tsc --build --watch", + "test": "bun test", + "bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts", "prepublishOnly": "npm run build" }, "peerDependencies": { diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 02fcf1e6..f67291e6 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -13,6 +13,8 @@ import type { LevelNode, RoofNode, RoofSegmentNode, + ScanNode, + ShelfNode, SiteNode, SlabNode, SpawnNode, @@ -35,7 +37,14 @@ export interface GridEvent { */ localPosition: [number, number, number] faceIndex?: number - object: Object3D + /** + * Optional: the hit Three.js object. Present when the grid event was + * synthesized from a R3F mesh hit (the legacy grid-plane mesh path); + * absent when emitted by the canvas-level raycaster in + * `use-grid-events.ts`, where there is no specific mesh to attribute + * the intersection to. + */ + object?: Object3D nativeEvent: ThreeEvent } @@ -57,6 +66,7 @@ export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent export type LevelEvent = NodeEvent export type ZoneEvent = NodeEvent +export type ShelfEvent = NodeEvent export type SlabEvent = NodeEvent export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent @@ -68,6 +78,8 @@ export type StairSegmentEvent = NodeEvent export type WindowEvent = NodeEvent export type DoorEvent = NodeEvent export type ElevatorEvent = NodeEvent +export type ScanEvent = NodeEvent +export type GuideEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -189,6 +201,7 @@ type EditorEvents = GridEvents & NodeEvents<'level', LevelEvent> & NodeEvents<'zone', ZoneEvent> & NodeEvents<'slab', SlabEvent> & + NodeEvents<'shelf', ShelfEvent> & NodeEvents<'spawn', SpawnEvent> & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'column', ColumnEvent> & @@ -198,6 +211,8 @@ type EditorEvents = GridEvents & NodeEvents<'stair-segment', StairSegmentEvent> & NodeEvents<'window', WindowEvent> & NodeEvents<'door', DoorEvent> & + NodeEvents<'scan', ScanEvent> & + NodeEvents<'guide', GuideEvent> & CameraControlEvents & ToolEvents & GuideEvents & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index ab1a1a89..39a84ec3 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -3,49 +3,61 @@ import { useLayoutEffect } from 'react' import type * as THREE from 'three' +// `byType` is a Proxy-backed Map keyed by kind. Sets are created lazily on +// first access, so any kind (built-in or plugin-contributed) participates +// without needing a hardcoded seed list. The previous `KNOWN_NODE_KINDS` +// array was a pre-seed for autocomplete; with every kind now flowing +// through `nodeRegistry`, the seed is redundant. +// +// The type expresses that *any* string key returns a `Set` — 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 } +const byTypeStore = new Map>() + +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() + byTypeStore.set(key, set) + } + return set + }, + ownKeys() { + return Array.from(byTypeStore.keys()) + }, + has(_target, key) { + return typeof key === 'string' && byTypeStore.has(key) + }, + getOwnPropertyDescriptor(_target, key) { + if (typeof key !== 'string') return undefined + const set = byTypeStore.get(key) + if (!set) return undefined + return { configurable: true, enumerable: true, value: set, writable: false } + }, +}) + export const sceneRegistry = { // Master lookup: ID -> Object3D nodes: new Map(), - // Categorized lookups: Type -> Set of IDs - // Using a Set is faster for adding/deleting than an Array - byType: { - site: new Set(), - building: new Set(), - ceiling: new Set(), - column: new Set(), - elevator: new Set(), - level: new Set(), - wall: new Set(), - fence: new Set(), - item: new Set(), - slab: new Set(), - spawn: new Set(), - zone: new Set(), - roof: new Set(), - 'roof-segment': new Set(), - stair: new Set(), - 'stair-segment': new Set(), - scan: new Set(), - guide: new Set(), - window: new Set(), - door: new Set(), - }, + // Categorized lookups: Kind -> Set of IDs. Backed by a Proxy so any kind + // gets a Set on first touch — no hardcoded list. + byType: byTypeProxy, /** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */ clear() { this.nodes.clear() - for (const set of Object.values(this.byType)) { + for (const set of byTypeStore.values()) { set.clear() } }, } -export function useRegistry( - id: string, - type: keyof typeof sceneRegistry.byType, - ref: React.RefObject, -) { +export function useRegistry(id: string, type: string, ref: React.RefObject) { useLayoutEffect(() => { const obj = ref.current if (!obj) return @@ -53,13 +65,13 @@ export function useRegistry( // 1. Add to master map sceneRegistry.nodes.set(id, obj) - // 2. Add to type-specific set - sceneRegistry.byType[type].add(id) + // 2. Add to type-specific set — Proxy auto-creates on first access. + sceneRegistry.byType[type]!.add(id) - // 4. Cleanup when component unmounts + // 3. Cleanup when component unmounts return () => { sceneRegistry.nodes.delete(id) - sceneRegistry.byType[type].delete(id) + sceneRegistry.byType[type]!.delete(id) } }, [id, type, ref]) } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index d64dbd7c..3829c4b9 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,11 +1,5 @@ -import { - type AnyNode, - type AnyNodeId, - getScaledDimensions, - type ItemNode, - type SlabNode, - type WallNode, -} from '../../schema' +import { nodeRegistry } from '../../registry' +import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' import useScene from '../../store/use-scene' import { itemOverlapsPolygon, @@ -135,31 +129,35 @@ function markNodesOverlappingSlab( const slabLevelId = resolveLevelId(slab, nodes) for (const node of Object.values(nodes)) { - if (node.type === 'item') { - const item = node as ItemNode - // Only floor items are affected by slabs - if (item.asset.attachTo) continue - if (resolveLevelId(node, nodes) !== slabLevelId) continue - if ( - itemOverlapsPolygon( - item.position, - getScaledDimensions(item), - item.rotation, - slab.polygon, - 0.01, - ) - ) { - markDirty(node.id) - } - } else if (node.type === 'wall') { + if (node.type === 'wall') { const wall = node as WallNode if (resolveLevelId(node, nodes) !== slabLevelId) continue if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) { markDirty(node.id) } - } else if (node.type === 'stair') { + continue + } + if (node.type === 'stair') { if (resolveLevelId(node, nodes) !== slabLevelId) continue markDirty(node.id) + continue + } + + // Generic floor-placed sweep: any registry kind that opts in via + // `capabilities.floorPlaced` (item / shelf / column / spawn / …) + // re-elevates through `` 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) } } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9d50139f..5a28397e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,11 +9,14 @@ export type { EventSuffix, FenceEvent, GridEvent, + GuideEvent, ItemEvent, LevelEvent, NodeEvent, RoofEvent, RoofSegmentEvent, + ScanEvent, + ShelfEvent, SiteEvent, SlabEvent, SpawnEvent, @@ -44,10 +47,10 @@ export { } from './lib/door-operation' export { getRenderableSlabPolygon } from './lib/slab-polygon' export { + type AutoSlabSyncPlan, detectSpacesForLevel, initSpaceDetectionSync, planAutoSlabsForLevel, - type AutoSlabSyncPlan, type Space, wallTouchesOthers, } from './lib/space-detection' @@ -63,7 +66,9 @@ export { type MaterialCategory, toLibraryMaterialRef, } from './material-library' +export * from './registry' export * from './schema' +export * from './services' export { getSceneHistoryPauseDepth, pauseSceneHistory, @@ -89,6 +94,7 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li export { clearSceneHistory, default as useScene } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { + type ElevatorDoorSide, getElevatorCabCenterZ, getElevatorCabDepth, getElevatorCabWidth, @@ -101,7 +107,6 @@ export { getResolvedElevatorDoorPanelStyle, getResolvedElevatorDoorStyle, getResolvedElevatorShaftStyle, - type ElevatorDoorSide, } from './systems/elevator/elevator-geometry' export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system' @@ -157,8 +162,8 @@ export { constrainWallMoveDeltaToAxis, getPerpendicularWallMoveAxis, planWallMoveJunctions, - type WallMoveBridgePlan, type WallMoveAxis, + type WallMoveBridgePlan, type WallMoveJunctionPlan, type WallPlanPoint, } from './systems/wall/wall-move' diff --git a/packages/core/src/registry/__bench__/relations-resolver.bench.ts b/packages/core/src/registry/__bench__/relations-resolver.bench.ts new file mode 100644 index 00000000..21ffd7e2 --- /dev/null +++ b/packages/core/src/registry/__bench__/relations-resolver.bench.ts @@ -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 = {} + const wallToSlabIds = new Map() + + 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): 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 +}) diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts new file mode 100644 index 00000000..d87e8b5d --- /dev/null +++ b/packages/core/src/registry/index.ts @@ -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' diff --git a/packages/core/src/registry/registry.test.ts b/packages/core/src/registry/registry.test.ts new file mode 100644 index 00000000..0e1abc0f --- /dev/null +++ b/packages/core/src/registry/registry.test.ts @@ -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 { + 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/) + }) +}) diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts new file mode 100644 index 00000000..50021bee --- /dev/null +++ b/packages/core/src/registry/registry.ts @@ -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() + + 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[] { + 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 { + 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 + +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 { + return pluginDiscovery() +} diff --git a/packages/core/src/registry/relations-resolver.test.ts b/packages/core/src/registry/relations-resolver.test.ts new file mode 100644 index 00000000..2784c1d1 --- /dev/null +++ b/packages/core/src/registry/relations-resolver.test.ts @@ -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 { + 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 { + return { + id: id(idStr), + type: kind, + parentId: null, + visible: true, + ...extra, + } as unknown as AnyNode +} + +function makeFakeScene(nodes: Record): 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) + 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 = {} + 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) + } + 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 = { + a1: makeNode('a', 'a1', { children: [id('a2')] } as Partial), + a2: makeNode('a', 'a2', { children: [id('a1')] } as Partial), // 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), + c1: makeNode('thing', 'c1', { children: [id('g1')] } as Partial), + 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), + b: makeNode('thing', 'b', { children: [id('c')] } as Partial), + c: makeNode('thing', 'c', { children: [id('d')] } as Partial), + 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 + }) +}) diff --git a/packages/core/src/registry/relations-resolver.ts b/packages/core/src/registry/relations-resolver.ts new file mode 100644 index 00000000..e406928f --- /dev/null +++ b/packages/core/src/registry/relations-resolver.ts @@ -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 + +/** + * 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 + +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 { + const result = new Set() + const maxDepth = ctx.maxDepth ?? DEFAULT_MAX_DEPTH + walk(startId, ctx, result, 0, maxDepth) + return result +} + +function walk( + id: AnyNodeId, + ctx: CascadeContext, + result: Set, + 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, +): Set { + const result = new Set() + const maxDepth = ctx.maxDepth ?? DEFAULT_MAX_DEPTH + walkDescendants(startId, ctx, result, 0, maxDepth) + return result +} + +function walkDescendants( + id: AnyNodeId, + ctx: Pick, + result: Set, + 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) + } +} diff --git a/packages/core/src/registry/scene-api.test.ts b/packages/core/src/registry/scene-api.test.ts new file mode 100644 index 00000000..fee5f175 --- /dev/null +++ b/packages/core/src/registry/scene-api.test.ts @@ -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 = {}) { + const state = { + nodes: { ...initial } as Record, + rootNodeIds: [] as AnyNodeId[], + dirtyNodes: new Set(), + createNode(node: AnyNode) { + state.nodes[node.id] = node + }, + updateNode(id: AnyNodeId, data: Partial) { + 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 = {}): 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) => + store._state.nodes as unknown as Record + +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) + 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) + 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) + api.update(id('b'), { visible: false } as Partial) + 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) + 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) + api.update(id('a'), { visible: true } as Partial) // second update — must not overwrite snapshot + api.update(id('a'), { visible: false } as Partial) + api.restore(id('a')) + expect(nodes(store)['a']).toMatchObject({ visible: true }) // the *first* pre-pause value + api.resumeHistory() + }) +}) diff --git a/packages/core/src/registry/scene-api.ts b/packages/core/src/registry/scene-api.ts new file mode 100644 index 00000000..65aa50fd --- /dev/null +++ b/packages/core/src/registry/scene-api.ts @@ -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 + rootNodeIds: AnyNodeId[] + dirtyNodes: Set + createNode: (node: AnyNode, parentId?: AnyNodeId) => void + updateNode: (id: AnyNodeId, data: Partial) => 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 | 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(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 + }, + } +} diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts new file mode 100644 index 00000000..515365e3 --- /dev/null +++ b/packages/core/src/registry/types.ts @@ -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: (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 `` 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 `` in `` 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 = { + start(args: { + node: N + /** Opaque kind-specific payload from the handle primitive. */ + payload: unknown + /** Current scene snapshot at drag start. */ + nodes: Record + /** 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 = (args: { + node: N + nodes: Record +}) => FloorplanMoveTargetSession + +// ─── Plugin manifest ───────────────────────────────────────────────── + +export type Plugin = { + id: string + apiVersion: 1 + nodes?: AnyNodeDefinition[] +} + +// ─── NodeDefinition ────────────────────────────────────────────────── + +export type AnyNodeDefinition = NodeDefinition> + +export type NodeDefinition> = { + kind: string + schemaVersion: number + schema: S + category: NodeCategory + + defaults: () => Omit, 'id' | 'type'> + migrate?: Record unknown> + + capabilities: Capabilities + relations?: Relations + parametrics?: ParametricDescriptor> + + /** + * 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 (``, `RegisteredSystems`) + * already null-guard on `def.renderer` so omitting it is safe. + */ + renderer?: RendererSource> + /** + * Pure geometry builder. When set, the framework's generic + * `` 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 + * `` 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, ``, GLB) AND parametric + * rebuilds; combine with `system` if you also need per-frame imperative + * work (animations, named-mesh material poking). + */ + geometry?: (node: z.infer, ctx: GeometryContext) => Object3D + /** + * Level-batch precompute hook. Called by `` 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>) => 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, 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>> + /** + * 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> + 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 Promise<{ default: ComponentType }>> + affordances?: Affordance>[] + /** + * Contextual shortcut hints shown by `HelperManager` when this kind's + * tool is active. Pure data — `HelperManager` renders these via a + * generic . Drops the need for a hand-written + * `` 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 }> }> + + 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 = + | { + 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> + 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 } + 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 `` + * 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 = { + groups: ParamGroup[] + invariants?: ReadonlyArray<(n: N) => Issue[]> + derive?: (n: N) => Partial + customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> +} + +export type ParamGroup = { + label: string + fields: ParamField[] +} + +export type ParamField = + | { + 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) => void }> + visibleIf?: (n: N) => boolean + } + +export type Issue = { field?: string; msg: string; severity?: 'error' | 'warning' } + +// ─── Affordance ────────────────────────────────────────────────────── + +export type Affordance = { + 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 = { + 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 + 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: (id: AnyNodeId) => N | undefined + update: (id: AnyNodeId, patch: Partial) => 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[] + readonly size: number +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index aede4b6a..5d542867 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -74,6 +74,7 @@ export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export { RoofSegmentNode, RoofType } from './nodes/roof-segment' export { ScanNode } from './nodes/scan' // Nodes +export { ShelfNode } from './nodes/shelf' export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' export { SpawnNode } from './nodes/spawn' diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index e2e76ec5..5e5f71e3 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -51,6 +51,7 @@ export const MaterialTarget = z.enum([ 'ceiling', 'door', 'window', + 'shelf', ]) export type MaterialTarget = z.infer diff --git a/packages/core/src/schema/nodes/shelf.ts b/packages/core/src/schema/nodes/shelf.ts new file mode 100644 index 00000000..58d7af5e --- /dev/null +++ b/packages/core/src/schema/nodes/shelf.ts @@ -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 diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index d04c4be3..a683fb12 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -11,6 +11,7 @@ import { LevelNode } from './nodes/level' import { RoofNode } from './nodes/roof' import { RoofSegmentNode } from './nodes/roof-segment' import { ScanNode } from './nodes/scan' +import { ShelfNode } from './nodes/shelf' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' import { SpawnNode } from './nodes/spawn' @@ -34,6 +35,7 @@ export const AnyNode = z.discriminatedUnion('type', [ CeilingNode, RoofNode, RoofSegmentNode, + ShelfNode, StairNode, StairSegmentNode, ScanNode, diff --git a/packages/core/src/services/drag-session.test.ts b/packages/core/src/services/drag-session.test.ts new file mode 100644 index 00000000..ab71eddb --- /dev/null +++ b/packages/core/src/services/drag-session.test.ts @@ -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 = {}): SceneApi & { + _calls: { + pauseHistory: number + resumeHistory: number + restoreAll: number + markedDirty: AnyNodeId[] + updated: Array<[AnyNodeId, Partial]> + } +} { + const calls = { + pauseHistory: 0, + resumeHistory: 0, + restoreAll: 0, + markedDirty: [] as AnyNodeId[], + updated: [] as Array<[AnyNodeId, Partial]>, + } + 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')) + }) +}) diff --git a/packages/core/src/services/drag-session.ts b/packages/core/src/services/drag-session.ts new file mode 100644 index 00000000..b58ae6e9 --- /dev/null +++ b/packages/core/src/services/drag-session.ts @@ -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 = { + /** 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( + action: DragAction, + scene: SceneApi, + options: DragSessionOptions = {}, +): DragSession { + let active = false + let ctx: Ctx | null = null + let draft: Draft | null = null + let dirtyMarked = new Set() + + 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() + } + }, + } +} diff --git a/packages/core/src/services/hosting.test.ts b/packages/core/src/services/hosting.test.ts new file mode 100644 index 00000000..f20de86e --- /dev/null +++ b/packages/core/src/services/hosting.test.ts @@ -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 { + 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): 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 = {} + 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')) + }) +}) diff --git a/packages/core/src/services/hosting.ts b/packages/core/src/services/hosting.ts new file mode 100644 index 00000000..6d15a69f --- /dev/null +++ b/packages/core/src/services/hosting.ts @@ -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 +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts new file mode 100644 index 00000000..ac4826f8 --- /dev/null +++ b/packages/core/src/services/index.ts @@ -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' diff --git a/packages/core/src/services/movement.test.ts b/packages/core/src/services/movement.test.ts new file mode 100644 index 00000000..53cf4cf3 --- /dev/null +++ b/packages/core/src/services/movement.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/services/movement.ts b/packages/core/src/services/movement.ts new file mode 100644 index 00000000..3e8e38a2 --- /dev/null +++ b/packages/core/src/services/movement.ts @@ -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 +} diff --git a/packages/core/src/services/single-undo-dance.test.ts b/packages/core/src/services/single-undo-dance.test.ts new file mode 100644 index 00000000..64442a38 --- /dev/null +++ b/packages/core/src/services/single-undo-dance.test.ts @@ -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) + scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial) + 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) + + 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) + scene.update(FENCE_ID, { curveOffset: 0.7 } as Partial) + // Commit dance. + scene.restoreAll() + scene.resumeHistory() + scene.update(FENCE_ID, { curveOffset: 0.7 } as Partial) + + 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) + // 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) => { + s.update(FENCE_ID, { curveOffset: draft.offset } as Partial) + return [FENCE_ID] + }, + commit: ( + draft: { offset: number }, + ctx: { original: number }, + s: ReturnType, + ) => { + if (draft.offset === ctx.original) return false + s.restoreAll() + s.resumeHistory() + s.update(FENCE_ID, { curveOffset: draft.offset } as Partial) + 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) + 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) + 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) + scene.restoreAll() + scene.resumeHistory() + scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial) + + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[FENCE_ID]).toBeDefined() + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[FENCE_ID]).toBeUndefined() + }) +}) diff --git a/packages/core/src/services/snap.test.ts b/packages/core/src/services/snap.test.ts new file mode 100644 index 00000000..1a4ddb38 --- /dev/null +++ b/packages/core/src/services/snap.test.ts @@ -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), + ) + }) +}) diff --git a/packages/core/src/services/snap.ts b/packages/core/src/services/snap.ts new file mode 100644 index 00000000..74aa9cd8 --- /dev/null +++ b/packages/core/src/services/snap.ts @@ -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, + }, +} diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 401385bb..2c5768a3 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -253,16 +253,23 @@ export const createNodesAction = ( nextNodes[newNode.id] = newNode - // 2. Update the Parent's children list + // 2. Update the Parent's children list. We append to ANY container + // parent (kind has `children` in its schema) — if the field is + // present but undefined (e.g. an old saved scene from before the + // kind gained children), we initialise to `[]` first so the + // reparenting goes through. Without this, hosting items on an + // old shelf (v1, before `children` was added) silently no-ops: + // the item is reparented to the shelf but the shelf's children + // array is never updated, so `ParametricNodeRenderer` doesn't + // mount it and the item "disappears". if (effectiveParentId && nextNodes[effectiveParentId]) { const parent = nextNodes[effectiveParentId] - - // Type Guard: Check if the parent node is a container that supports children - if ('children' in parent && Array.isArray(parent.children)) { + if ('children' in parent) { + const existing = (parent as { children?: unknown }).children + const children = Array.isArray(existing) ? (existing as AnyNodeId[]) : [] nextNodes[effectiveParentId] = { ...parent, - // Use Set to prevent duplicate IDs if createNode is called twice - children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here + children: Array.from(new Set([...children, newNode.id])) as any, } } } else if (!effectiveParentId) { @@ -442,20 +449,31 @@ export const updateNodesAction = ( const oldParentId = currentNode.parentId as AnyNodeId | null if (oldParentId && nextNodes[oldParentId]) { const oldParent = nextNodes[oldParentId] as AnyContainerNode + const oldChildren = Array.isArray((oldParent as { children?: unknown }).children) + ? (oldParent as { children: AnyNodeId[] }).children + : [] nextNodes[oldParent.id] = { ...oldParent, - children: oldParent.children.filter((childId) => childId !== id), + children: oldChildren.filter((childId) => childId !== id), } as AnyNode parentsToUpdate.add(oldParent.id) } - // 2. Add to new parent + // 2. Add to new parent. Defensive against parents that don't yet + // carry a `children` array — older saved scenes can predate the + // schema field on a particular kind (shelf v1 → v2 added one), + // and a spread of `undefined` here throws and aborts the entire + // `set` callback. Initialising to `[]` matches what the schema's + // default would have produced. const newParentId = data.parentId as AnyNodeId | null if (newParentId && nextNodes[newParentId]) { const newParent = nextNodes[newParentId] as AnyContainerNode + const newChildren = Array.isArray((newParent as { children?: unknown }).children) + ? (newParent as { children: AnyNodeId[] }).children + : [] nextNodes[newParent.id] = { ...newParent, - children: Array.from(new Set([...newParent.children, id])), + children: Array.from(new Set([...newChildren, id])), } as AnyNode parentsToUpdate.add(newParent.id) } diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 48615bbf..39f28c00 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -342,6 +342,16 @@ function migrateNodes(nodes: Record): Record { patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) } + // Shelf v2: hosting was added in this migration cycle. Older shelves + // (saved before the schema gained `children`) need the field + // initialised so `createNode(item, shelfId)` finds an array to + // append the child id to — without this the host item ends up + // orphaned (parented in scene state but not in the shelf's + // children list, so the renderer doesn't mount it). + if (node.type === 'shelf' && !Array.isArray(node.children)) { + patchedNodes[id] = { ...node, children: [] } + } + if (node.type === 'roof') { patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id]) } diff --git a/packages/core/src/systems/elevator/elevator-geometry.ts b/packages/core/src/systems/elevator/elevator-geometry.ts index c08b296b..ad7fe62c 100644 --- a/packages/core/src/systems/elevator/elevator-geometry.ts +++ b/packages/core/src/systems/elevator/elevator-geometry.ts @@ -73,17 +73,11 @@ export function getElevatorShaftWallThickness(node: ElevatorNode) { return Math.max(node.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, 0.04) } -export function getElevatorShaftWidth( - node: ElevatorNode, - cabWidth = getElevatorCabWidth(node), -) { +export function getElevatorShaftWidth(node: ElevatorNode, cabWidth = getElevatorCabWidth(node)) { return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8) } -export function getElevatorShaftDepth( - node: ElevatorNode, - cabDepth = getElevatorCabDepth(node), -) { +export function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDepth(node)) { return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8) } diff --git a/packages/core/src/systems/elevator/elevator-runtime.test.ts b/packages/core/src/systems/elevator/elevator-runtime.test.ts index 959dd681..4ebedb2b 100644 --- a/packages/core/src/systems/elevator/elevator-runtime.test.ts +++ b/packages/core/src/systems/elevator/elevator-runtime.test.ts @@ -42,7 +42,10 @@ describe('elevator runtime helpers', () => { }) test('moves to a queued level and clears the served request on arrival', () => { - const queued = queueElevatorRequest(createElevatorInteractiveState(groundLevelId, 0), upperLevelId) + const queued = queueElevatorRequest( + createElevatorInteractiveState(groundLevelId, 0), + upperLevelId, + ) const moving = stepElevatorRuntimeState({ defaultEntry: entries[0]!, delta: 0.016, diff --git a/packages/core/src/systems/elevator/elevator-runtime.ts b/packages/core/src/systems/elevator/elevator-runtime.ts index c75a30bc..bba14e0d 100644 --- a/packages/core/src/systems/elevator/elevator-runtime.ts +++ b/packages/core/src/systems/elevator/elevator-runtime.ts @@ -1,7 +1,7 @@ import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema' import { type ElevatorInteractiveState, useInteractive } from '../../store/use-interactive' import useScene from '../../store/use-scene' -import { resolveElevatorLevels, type ElevatorLevelEntry } from './elevator-service' +import { type ElevatorLevelEntry, resolveElevatorLevels } from './elevator-service' const EPSILON = 0.001 @@ -67,9 +67,7 @@ export function queueElevatorRequest( } } -export function openElevatorDoorState( - state: ElevatorInteractiveState, -): ElevatorInteractiveState { +export function openElevatorDoorState(state: ElevatorInteractiveState): ElevatorInteractiveState { if (!state.currentLevelId || state.phase === 'moving') return state return { @@ -246,7 +244,9 @@ export function stepElevatorRuntimes(now: number, delta: number) { const state = useInteractive.getState().elevators[elevatorId] if (!state) { - useInteractive.getState().initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) + useInteractive + .getState() + .initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) continue } diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts index 59798da5..5239434c 100644 --- a/packages/core/src/systems/elevator/elevator-service.ts +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -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 diff --git a/packages/core/src/systems/wall/wall-move.ts b/packages/core/src/systems/wall/wall-move.ts index e9b6595c..f6240c77 100644 --- a/packages/core/src/systems/wall/wall-move.ts +++ b/packages/core/src/systems/wall/wall-move.ts @@ -12,9 +12,7 @@ export type WallMoveBridgePlan, -> = { +export type WallMoveLinkedWallTargetPlan> = { wall: TWall originalPoint: WallPlanPoint targetPoint: WallPlanPoint @@ -174,7 +172,9 @@ export function planWallMoveJunctions a.distance - b.distance)[0] if (consumedSameDirectionWall) { - const pivotPoint = [...otherWallEndpoint(consumedSameDirectionWall.wall, point)] as WallPlanPoint + const pivotPoint = [ + ...otherWallEndpoint(consumedSameDirectionWall.wall, point), + ] as WallPlanPoint const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction') wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall) @@ -201,14 +201,22 @@ export function planWallMoveJunctions wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint), + (wall) => + wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint), ) .map((wall) => ({ wall, relation: getMoveWallRelation(wall, pivotPoint, nextPoint), })) - addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true) + addStandardEndpointPlan( + endpoint, + pivotPoint, + nextPoint, + linkedAtPivot, + ':through-pivot', + true, + ) return } diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx new file mode 100644 index 00000000..988fb2a5 --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -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]` 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 + * `` / 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, + ) + } + + 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) + : {} + 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( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
, + document.body, + ) +} diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx new file mode 100644 index 00000000..2eb5f496 --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -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 ``'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 + }) => 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 `` 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 }> = [] + for (const snap of snapshots) { + const current = sceneState[snap.id] + if (!current) continue + const data: Record = {} + let changed = false + for (const [key, before] of Object.entries(snap.data)) { + const after = (current as unknown as Record)[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 }).metadata ?? {}) + : {} + if (meta.isNew) { + useScene.getState().updateNodes([ + { + id: snap.id, + data: { metadata: { ...meta, isNew: false } } as Record, + }, + ]) + } + } + 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)[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) + const meta = (movingNode as unknown as { metadata?: Record }).metadata + if (meta?.isNew) { + useScene.getState().updateNode( + movingNode.id as AnyNodeId, + { + metadata: { ...meta, isNew: false }, + } as Partial, + ) + } + } + 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 } + +function snapshotNode(node: AnyNode): NodeSnapshot { + const data: Record = {} + 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) + const bKeys = Object.keys(b as Record) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!deepEqual((a as Record)[key], (b as Record)[key])) { + return false + } + } + return true + } + return false +} diff --git a/packages/editor/src/components/editor-2d/floorplan-render-context.tsx b/packages/editor/src/components/editor-2d/floorplan-render-context.tsx new file mode 100644 index 00000000..19a1bd85 --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-render-context.tsx @@ -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 ``. + * + * The legacy panel is the authoritative owner of the floor-plan SVG — + * it computes `unitsPerPixel` from the viewBox / surface size, mounts the + * pan/zoom ``, and knows the active theme. The registry layer is mounted + * inside the same ``, 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 `` id mounted in `` by the legacy panel for selection hatch fills. */ + hatchPatternId: string +} + +const FloorplanRenderContext = createContext(null) + +export function FloorplanRenderProvider({ + children, + unitsPerPixel, + palette, + hatchPatternId, +}: FloorplanRenderContextValue & { children: ReactNode }) { + const value = useMemo( + () => ({ unitsPerPixel, palette, hatchPatternId }), + [unitsPerPixel, palette, hatchPatternId], + ) + return {children} +} + +/** + * 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) +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx new file mode 100644 index 00000000..890f5eaa --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx @@ -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 }) { + // 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 + + case 'polygon': + return + + case 'polyline': + return + + case 'rect': + return ( + + ) + + case 'circle': + return + + case 'line': + return + + case 'text': + return ( + + {g.text} + + ) + + case 'image': + return ( + + ) + + case 'group': { + const transform = formatTransform(g.transform) + return ( + + {g.children.map((child, i) => renderNode(child, i))} + + ) + } + + // 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 + * `` 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(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 ( + + + + ) +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx new file mode 100644 index 00000000..3f1cc341 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -0,0 +1,1346 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type FloorplanAffordancePoint, + type FloorplanAffordanceSession, + type FloorplanGeometry, + type FloorplanPalette, + type GeometryContext, + nodeRegistry, + pauseSceneHistory, + resumeSceneHistory, + useInteractive, + useLiveNodeOverrides, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { + memo, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { useFloorplanRender } from '../floorplan-render-context' +import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' + +/** + * Registry-driven floor-plan layer. + * + * For every node in the active level whose definition exposes + * `def.floorplan`, builds a `GeometryContext` (with `viewState` so the + * kind can theme its output), calls the builder, and walks the resulting + * tree. Static primitives (polygon / line / circle / etc.) defer to + * ``. Interactive primitives — `hatch`, + * `hit-line`, `endpoint-handle`, `dimension-label` — render here so they + * can access the SVG context for pointer events + units-per-pixel. + * + * Selection: clicking the entry's `` selects the node. The wall + * `def.floorplan` also emits a `hit-line` along the centerline so the + * user can grab the wall body even at zoom levels where the polygon is + * skinny. + * + * 2D endpoint drag: when an `endpoint-handle` is pointer-downed and its + * `affordance === 'move-endpoint'`, this layer drives the legacy wall + * endpoint flow inline — snap pointer to walls/grid, run linked-wall + * cascade, live-update positions with history paused, single undo on + * commit. The kind-generic abstraction lands once fence + slab + ceiling + * pick up their 2D drags too (next iteration). + */ +// Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*` +// constants in floorplan-panel.tsx. Sizes are in screen pixels — the +// dispatcher multiplies by `unitsPerPixel` so handles stay the same on- +// screen size at any zoom. +const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8 +const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9 +const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3 +const ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX = 4 +const ENDPOINT_HIT_STROKE_WIDTH_PX = 18 +const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16 +const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7 +const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)' + +/** + * Snapshot of node fields captured at drag-start, used by the single-undo + * dance to revert untracked before re-applying as a single tracked + * change. The dispatcher only knows about the `affectedIds` the + * affordance declares; it captures whatever fields exist on each node by + * cloning the full record minus the registry-managed `id` / `type`. + */ +type NodeSnapshot = { id: AnyNodeId; data: Record } + +type ActiveDrag = { + pointerId: number + /** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */ + handleId: string + session: FloorplanAffordanceSession + snapshots: NodeSnapshot[] + historyPaused: boolean +} + +function snapshotNode(node: AnyNode): NodeSnapshot { + // Shallow-clone every non-id, non-type field. Arrays / vec tuples are + // deep-cloned to detach from the live store reference. + const data: Record = {} + for (const [key, value] of Object.entries(node)) { + if (key === 'id' || key === 'type' || key === 'object' || key === 'parentId') 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 })) +} + +export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { + const levelId = useViewer((s) => s.selection.levelId) + const selectedIds = useViewer((s) => s.selection.selectedIds) + const previewSelectedIds = useViewer((s) => s.previewSelectedIds) + const hoveredId = useViewer((s) => s.hoveredId) + const setHoveredId = useViewer((s) => s.setHoveredId) + const setSelection = useViewer((s) => s.setSelection) + const nodes = useScene((s) => s.nodes) + const renderCtx = useFloorplanRender() + const movingNode = useEditor((s) => s.movingNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + // Subscribe to the live-transforms map ref so the layer re-renders + // whenever a 3D mover publishes a per-frame position (see + // `usePlacementCoordinator`). Without this the 2D floor plan only + // updates after 3D commit — the 3D drag would look frozen in 2D. + const liveTransforms = useLiveTransforms((s) => s.transforms) + // Same reactivity hook for elevator runtime state — `useInteractive` + // tracks the current / fallback level + cab travel, `useLiveNode + // Overrides` carries live-edit overrides from the inspector. Builders + // read both via `getState()` inside `def.floorplan`; subscribing here + // is what forces the layer to re-render when they change. + const liveOverrides = useLiveNodeOverrides((s) => s.overrides) + const interactiveElevators = useInteractive((s) => s.elevators) + + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + // Marquee preview selection — matches the legacy `highlightedIdSet` use + // (filter-while-marquee), surfaces selection chrome without keyboard focus. + const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds]) + + // Interactive state lives in refs; only the visible feedback bits go + // into React state to keep re-renders cheap during drag. + const dragRef = useRef(null) + const [hoveredHandleId, setHoveredHandleId] = useState(null) + const [activeDragId, setActiveDragId] = useState(null) + + const handleSelect = useCallback( + (id: AnyNodeId, event: React.PointerEvent) => { + if (event.button !== 0) return + event.stopPropagation() + setSelection({ selectedIds: [id] }) + }, + [setSelection], + ) + + const handleClickStop = useCallback((event: React.MouseEvent) => { + event.stopPropagation() + }, []) + + // Build the geometry list. `viewState` flows into ctx so kinds can + // theme their output and conditionally emit selection chrome. + // + // Each entry carries TWO trees: + // - `base`: filled shapes, strokes, polygons, hatches — anything + // that should respect the kind's z-order bucket. + // - `overlay`: interactive handles (vertex / midpoint / edge / move) + // and labels (text / dimension). These always render on top of + // every base entry so selection chrome and node names stay visible + // above walls, items, etc. + // + // The split is computed by `splitFloorplanOverlay` from the single + // tree the builder returns. Builders don't need to know about the + // partition. + const entries = useMemo(() => { + if (!levelId) return [] + const out: { + id: AnyNodeId + node: AnyNode + base: FloorplanGeometry | null + overlay: FloorplanGeometry | null + selected: boolean + highlighted: boolean + }[] = [] + + const visit = (id: AnyNodeId) => { + const node = nodes[id] + if (!node) return + const def = nodeRegistry.get(node.type) + const builder = def?.floorplan + if (builder) { + const selected = selectedIdSet.has(id) + const highlighted = highlightedIdSet.has(id) + const hovered = hoveredId === id + const moving = movingNode?.id === id + // Live-transform override — when a mover is publishing per-frame + // position/rotation, render that here instead of the committed + // scene state. Without this the 2D floor plan would only update + // after commit, making the drag look frozen. + // + // The live-transform contract varies per kind (see + // wiki/architecture/tools.md "useLiveTransforms contract is + // per-kind, not generic"); we narrow per kind here: + // - item: world-plan position frame. Override `position` + + // `rotation` and force `parentId: null` so the resolver + // treats them as world coords directly. + // - slab / ceiling: position is a translation **delta** + // (`[Δx, 0, Δz]`). Translate the polygon + holes by the + // delta — the floor-plan builder draws the polygon at its + // new location, mirroring the 3D `` + // visual without forcing per-tick CSG scene writes. + const live = liveTransforms.get(id) + let effectiveNode: AnyNode = node + if (live) { + if (node.type === 'item' || node.type === 'shelf') { + // World-plan position kinds: the live transform carries the + // node's intended position/rotation in level-local coords. + // Override both and force `parentId: null` so the floor-plan + // resolver treats `position` as world plan coords directly + // (skipping the parent-chain transform composition). + effectiveNode = { + ...node, + position: live.position, + rotation: [0, live.rotation, 0] as [number, number, number], + parentId: null, + } as AnyNode + } else if (node.type === 'slab' || node.type === 'ceiling') { + const dx = live.position[0] + const dz = live.position[2] + if (dx !== 0 || dz !== 0) { + const surface = node as { + polygon: Array<[number, number]> + holes?: Array> + } + effectiveNode = { + ...node, + polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), + holes: (surface.holes ?? []).map((h) => + h.map(([x, z]) => [x + dx, z + dz] as [number, number]), + ), + } as AnyNode + } + } + } + const ctx = buildContext(effectiveNode, nodes, { + selected, + highlighted, + hovered, + moving, + palette: renderCtx?.palette, + }) + const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)( + effectiveNode, + ctx, + ) + if (geometry) { + const { base, overlay } = splitFloorplanOverlay(geometry) + out.push({ id, node: effectiveNode, base, overlay, selected, highlighted }) + } + } + const childIds = (node as unknown as { children?: AnyNodeId[] }).children + if (Array.isArray(childIds)) { + for (const cid of childIds) visit(cid) + } + } + + visit(levelId as AnyNodeId) + + // Stable z-order sort. SVG renders in document order — later siblings + // paint on top of earlier ones — so anything that should sit *under* + // other floor-plan geometry has to come first in the entries array. + // Zones are conceptual room/area regions; walls / slabs / furniture + // all belong on top of them. Within a layer bucket we preserve the + // DFS visit order (stable sort) so siblings keep their relative + // priority. + out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type)) + return out + }, [ + levelId, + nodes, + liveTransforms, + liveOverrides, + interactiveElevators, + selectedIdSet, + highlightedIdSet, + hoveredId, + movingNode?.id, + renderCtx?.palette, + ]) + + // ── Generic 2D affordance dispatch ───────────────────────────────── + // + // Pointer-down on an interactive handle resolves the kind's + // `def.floorplanAffordances?.[affordance]` and starts a session. The + // dispatcher then owns: history pause/resume, snapshot capture, + // pointer-move/up/cancel routing, and the single-undo dance on + // commit. Each kind owns the actual mutation logic inside `apply`. + const startAffordanceDrag = useCallback( + ( + nodeId: AnyNodeId, + handleId: string, + affordance: string, + payload: unknown, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) return + if (movingNode) return + + const sceneNodes = useScene.getState().nodes + const node = sceneNodes[nodeId] + if (!node) return + + const def = nodeRegistry.get(node.type) + const handler = def?.floorplanAffordances?.[affordance] + if (!handler) return + + const initialPlanPoint = clientToPlan(event.clientX, event.clientY) + if (!initialPlanPoint) return + + event.preventDefault() + event.stopPropagation() + + const session = handler.start({ + node, + payload, + nodes: sceneNodes, + initialPlanPoint, + }) + + const snapshots: NodeSnapshot[] = [] + for (const id of session.affectedIds) { + const n = sceneNodes[id] + if (n) snapshots.push(snapshotNode(n)) + } + + pauseSceneHistory(useScene) + + dragRef.current = { + pointerId: event.pointerId, + handleId, + session, + snapshots, + historyPaused: true, + } + setActiveDragId(handleId) + setSelection({ selectedIds: [nodeId] }) + ;(event.currentTarget as Element).setPointerCapture?.(event.pointerId) + }, + [movingNode, setSelection], + ) + + useEffect(() => { + const onPointerMove = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + + const planPoint = clientToPlan(event.clientX, event.clientY) + if (!planPoint) return + + drag.session.apply({ + planPoint, + modifiers: { + shiftKey: event.shiftKey, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }, + }) + } + + const onPointerUp = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + + const commitValid = drag.session.canCommit() + + // Capture the final state BEFORE the revert so we know what to + // re-apply post-resume. + const sceneNodes = useScene.getState().nodes + const finalUpdates: Array<{ id: AnyNodeId; data: Record }> = [] + for (const snap of drag.snapshots) { + const current = sceneNodes[snap.id] + if (!current) continue + const data: Record = {} + let changed = false + for (const [key, before] of Object.entries(snap.data)) { + const after = (current as unknown as Record)[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 (mirrors the 3D move-endpoint-tool): + // 1. Revert to baseline while history is still paused (untracked). + // 2. Resume history. + // 3. Re-apply the final state — recorded as one tracked change. + useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots)) + if (drag.historyPaused) { + resumeSceneHistory(useScene) + drag.historyPaused = false + } + useScene.getState().updateNodes(finalUpdates) + sfxEmitter.emit('sfx:structure-build') + } else { + // Either no net change or canCommit() rejected — revert and + // resume without committing. + useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots)) + if (drag.historyPaused) { + resumeSceneHistory(useScene) + drag.historyPaused = false + } + } + + dragRef.current = null + setActiveDragId(null) + } + + const onPointerCancel = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + + // Revert untracked, then resume — no history entry is recorded. + useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots)) + if (drag.historyPaused) { + resumeSceneHistory(useScene) + drag.historyPaused = false + } + + dragRef.current = null + setActiveDragId(null) + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + // Component unmounted mid-drag — restore the baseline and unpause + // history so we don't leak a paused store across mounts. + const drag = dragRef.current + if (drag) { + useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots)) + if (drag.historyPaused) { + resumeSceneHistory(useScene) + } + dragRef.current = null + } + } + }, []) + + if (entries.length === 0) return null + + const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1 + const palette = renderCtx?.palette + + const renderEntry = (id: AnyNodeId, geometry: FloorplanGeometry, key: string) => ( + handleSelect(id, e)} + // Mirror the sidebar tree nodes' hover wiring — `useViewer. + // hoveredId` drives the highlight halo in 3D as well as the + // wall / fence floor-plan hover stroke. Setting it on + // pointer-enter and clearing on leave keeps the two views in + // sync. Without this the registry-driven kinds had hover + // visuals defined but never reached because the entry `` + // never updated the store. + onPointerEnter={() => setHoveredId(id)} + onPointerLeave={() => { + // Only clear when this entry is the one we last set — + // avoids racing with sibling entries during fast-moving + // pointer scans. + if (useViewer.getState().hoveredId === id) setHoveredId(null) + }} + style={{ cursor: 'pointer' }} + > + + startAffordanceDrag(id, makeHandleId(id, payload), affordance, payload, event) + } + onMoveHandlePointerDown={(event) => { + if (event.button !== 0) return + const node = useScene.getState().nodes[id] + if (!node) return + event.preventDefault() + event.stopPropagation() + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node as never) + }} + palette={palette} + unitsPerPixel={unitsPerPixel} + /> + + ) + + return ( + // The outer wrapper stops `click` events that escape an entry's + // `onClick={handleClickStop}`. The base+overlay split means + // pointer-down can land on the base `` and pointer-up on the + // overlay `` (selection mounts the overlay on top mid-gesture). + // When the down/up targets differ, the browser dispatches `click` + // to the lowest common ancestor — which sits ABOVE the entry-level + // handler. Without this guard the click reaches the SVG's + // `handleBackgroundClick`, which calls + // `resolveFloorplanBackgroundSelection` → `clear-elements` (because + // registry-driven items aren't in the legacy hit-test set) → + // clearing the selection that pointer-down just set, so items + // appear to "deselect themselves a fraction of a second after + // clicking." Scoped to `onClick` so hover / drag / pointer events + // still propagate normally inside the registry tree. + + {/* Base pass — rank-sorted body geometry (polygons, paths, fills, + strokes, hatches). Lower-rank kinds (zones) paint first so + higher-rank kinds (slabs, then walls / items / shelves) layer + on top in the expected document-order z-stack. */} + + {entries.map(({ id, base }) => + base ? renderEntry(id, base, `base-${id}`) : null, + )} + + {/* Overlay pass — interactive handles (vertex / midpoint / edge / + move) and labels (text / dimensions). Painted after every base + entry so polygon-editor chrome on a selected slab stays above + neighbouring walls, and a zone name stays readable above the + slab + wall geometry sitting on top of the zone. Each overlay + still routes through the same selection-handling `` so a + click on a zone's name selects the zone. */} + + {entries.map(({ id, overlay }) => + overlay ? renderEntry(id, overlay, `overlay-${id}`) : null, + )} + + + ) +}) + +// ── Interactive geometry walker ────────────────────────────────────── + +function InteractiveGeometry({ + geometry, + unitsPerPixel, + palette, + hatchPatternId, + hoveredHandleId, + activeDragId, + nodeId, + onHandleHoverChange, + onHandlePointerDown, + onMoveHandlePointerDown, +}: { + geometry: FloorplanGeometry + unitsPerPixel: number + palette: FloorplanPalette | undefined + hatchPatternId: string | undefined + hoveredHandleId: string | null + activeDragId: string | null + nodeId: AnyNodeId + onHandleHoverChange: (id: string | null) => void + onHandlePointerDown: ( + affordance: string, + payload: unknown, + event: ReactPointerEvent, + ) => void + onMoveHandlePointerDown: (event: ReactPointerEvent) => void +}): React.ReactElement { + return renderInteractive(geometry, 0) + + function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement { + switch (g.kind) { + case 'group': { + const transform = formatGroupTransform(g.transform) + return ( + + {g.children.map((child, i) => renderInteractive(child, i))} + + ) + } + case 'hatch': { + if (!hatchPatternId) return <> + return ( + `${x},${y}`).join(' ')} + /> + ) + } + case 'hit-line': { + return ( + + ) + } + case 'endpoint-handle': { + if (!palette) return <> + const handleId = makeHandleId(nodeId, g.payload) + const isHovered = hoveredHandleId === handleId + const isActive = activeDragId === handleId + // Variant picks the colour-set. Endpoint dots use the orange + // legacy palette; curve sagitta dots use the teal set so users + // can tell them apart at a glance. + const isCurve = g.variant === 'curve' + const stroke = isCurve + ? palette.curveHandleStroke + : isActive + ? palette.endpointHandleActiveStroke + : palette.endpointHandleStroke + const hoverStroke = isCurve + ? palette.curveHandleHoverStroke + : isActive + ? palette.endpointHandleActiveStroke + : palette.endpointHandleHoverStroke + const fill = isCurve + ? palette.curveHandleFill + : isActive + ? palette.endpointHandleActiveFill + : palette.endpointHandleFill + const outerRadius = + (isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) * + unitsPerPixel + const dotRadius = + (isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) * + unitsPerPixel + return ( + e.stopPropagation()} + onPointerEnter={() => onHandleHoverChange(handleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + + + + + + onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent) + } + pointerEvents="all" + r={outerRadius} + stroke="transparent" + strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel} + style={{ cursor: 'pointer' }} + vectorEffect="non-scaling-stroke" + /> + + ) + } + case 'move-handle': { + if (!palette) return <> + const moveHandleId = `${nodeId}:move` + const isHovered = hoveredHandleId === moveHandleId + // Move dots are visually bigger than endpoint handles — the + // legacy prod render uses ~13px outer / ~6px dot. Endpoint + // handles top out at 8/9px because there are usually two per + // wall + linked walls + curve handle nearby; the move dot is + // a singleton centerpiece so it can afford the extra weight. + const baseRadiusPx = 13 + const hoverRadiusPx = 15 + const outerRadius = (isHovered ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel + const dotRadius = 6 * unitsPerPixel + // Same 5-circle stack as the orange endpoint dot — hover glow + + // hover ring + filled outer + inner dot + transparent hit. On + // pointer-down, the layer calls `setMovingNode(node)`, which + // FloorplanRegistryMoveOverlay picks up and routes to the + // kind's `def.floorplanMoveTarget`. + return ( + e.stopPropagation()} + onPointerEnter={() => onHandleHoverChange(moveHandleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + + + + + onMoveHandlePointerDown(e as ReactPointerEvent)} + pointerEvents="all" + r={outerRadius} + stroke="transparent" + strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel} + style={{ cursor: 'move' }} + vectorEffect="non-scaling-stroke" + /> + + ) + } + case 'edge-handle': { + if (!palette) return <> + const handleId = makeHandleId(nodeId, g.payload) + const isHovered = hoveredHandleId === handleId + const isActive = activeDragId === handleId + const showVisible = isHovered || isActive + const stroke = isActive ? palette.endpointHandleActiveStroke : palette.selectedStroke + // Stroke widths in screen pixels — non-scaling-stroke keeps the + // hit area + glow consistent at every zoom. + const glowWidthPx = 14 + const visibleWidthPx = 3 + const hitWidthPx = 18 + return ( + e.stopPropagation()} + onPointerEnter={() => onHandleHoverChange(handleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + {/* Soft glow — visible only on hover / active. */} + + {/* Solid stroke on top — slightly more opaque when active. */} + + {/* Transparent hit area along the edge. */} + + onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent) + } + pointerEvents="stroke" + stroke="transparent" + strokeLinecap="round" + strokeWidth={hitWidthPx * unitsPerPixel} + style={{ cursor: 'pointer' }} + vectorEffect="non-scaling-stroke" + x1={g.x1} + x2={g.x2} + y1={g.y1} + y2={g.y2} + /> + + ) + } + case 'midpoint-handle': { + if (!palette) return <> + const handleId = makeHandleId(nodeId, g.payload) + const isHovered = hoveredHandleId === handleId + const isActive = activeDragId === handleId + const stroke = palette.endpointHandleStroke + const hoverStroke = palette.endpointHandleHoverStroke + // Slightly smaller than endpoint dots; hover-expanded. + const baseRadiusPx = 6 + const hoverRadiusPx = 8 + const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel + const plusHalf = 3 * unitsPerPixel + return ( + e.stopPropagation()} + onPointerEnter={() => onHandleHoverChange(handleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + + + {/* `+` icon — only when the user is close enough to see it + clearly (hover or active state). Keeps the resting state + visually quiet on busy polygons. */} + + + + onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent) + } + pointerEvents="all" + r={radius + unitsPerPixel * 2} + stroke="transparent" + strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel} + style={{ cursor: 'pointer' }} + vectorEffect="non-scaling-stroke" + /> + + ) + } + case 'dimension-label': { + if (!palette) return <> + // Flip the label upright if it would otherwise be upside-down + // (legacy floorplan-panel.tsx does the same — see line ~2548). + let degrees = (g.angle * 180) / Math.PI + if (degrees > 90) degrees -= 180 + else if (degrees <= -90) degrees += 180 + + const padX = unitsPerPixel * 6 + const padY = unitsPerPixel * 3 + const fontSize = Math.max(unitsPerPixel * 10, 0.08) + // Rough text width approximation — SVG can't measure text without + // the DOM. 6.2px per char at 10px font keeps the plate visually + // balanced for the short length strings ("3.24m", "1'2\"", etc.). + const textWidth = g.text.length * unitsPerPixel * 6.2 + const plateW = textWidth + padX * 2 + const plateH = fontSize + padY * 2 + return ( + + + + {g.text} + + + ) + } + case 'dimension': { + if (!palette) return <> + const stroke = g.stroke ?? palette.measurementStroke + // Offset endpoints along the outward normal — this is where the + // dimension line sits, parallel to the edge. + const ox = g.offsetNormal[0] * g.offsetDistance + const oy = g.offsetNormal[1] * g.offsetDistance + const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy] + const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy] + + // Extension line endpoints — extend past the dimension line by + // `extensionOvershoot` so the tip clears the dimension stroke. + const eOvershoot = g.extensionOvershoot + const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot) + const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot) + const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy] + const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy] + + const dx = dEnd[0] - dStart[0] + const dy = dEnd[1] - dStart[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return <> + const dirX = dx / length + const dirY = dy / length + + // Plan-unit constants matching the legacy `floorplan- + // measurements-layer.tsx`. `strokeWidth` is intentionally a + // raw value (not multiplied by `unitsPerPixel`) because every + // stroke here uses `vectorEffect: non-scaling-stroke` — the + // browser interprets it as screen-pixel-stable. Multiplying + // by `unitsPerPixel` would shrink the strokes by ~100× and + // make them invisible. Tick length, dash pattern, font size, + // and the label gap stay in plan units (they're geometry, + // not stroke width). + const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2 + const perpX = -dirY * tickHalf + const perpY = dirX * tickHalf + + const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE + const labelGap = 0.5 // plan units — gap in the dimension line for the label + const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04) + + const midX = (dStart[0] + dEnd[0]) / 2 + const midY = (dStart[1] + dEnd[1]) / 2 + const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf] + const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf] + + let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI + if (labelDeg > 90) labelDeg -= 180 + else if (labelDeg <= -90) labelDeg += 180 + + return ( + + {/* Extension lines (dashed). */} + + + {/* Dimension line: two halves with the label in between. */} + + + {/* End ticks. */} + + + {/* Rotated label centered in the gap. */} + + {g.text} + + + ) + } + default: + return + } + } +} + +// ── Helpers ────────────────────────────────────────────────────────── + +function buildContext( + node: AnyNode, + nodes: Record, + viewState: { + selected: boolean + highlighted: boolean + hovered: boolean + moving: boolean + palette: FloorplanPalette | undefined + }, +): GeometryContext { + const resolve = (id: AnyNodeId): N | undefined => nodes[id] as N | undefined + + const childIds = (node as unknown as { children?: AnyNodeId[] }).children + const children: AnyNode[] = Array.isArray(childIds) + ? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined) + : [] + + const parentId = node.parentId as AnyNodeId | null + const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null + + let siblings: AnyNode[] = [] + if (parent) { + const parentChildIds = (parent as unknown as { children?: AnyNodeId[] }).children + if (Array.isArray(parentChildIds)) { + for (const sid of parentChildIds) { + if (sid === node.id) continue + const s = nodes[sid] + if (s && s.type === node.type) siblings.push(s) + } + } else { + siblings = Object.values(nodes).filter( + (n) => n !== node && n.type === node.type && n.parentId === parentId, + ) + } + } + + return { + resolve, + children, + siblings, + parent, + viewState: viewState.palette + ? { + selected: viewState.selected, + highlighted: viewState.highlighted, + hovered: viewState.hovered, + moving: viewState.moving, + palette: viewState.palette, + } + : undefined, + } +} + +/** + * Stable id for a handle on a node, derived from the node id + opaque + * payload. Used to track hover / active visual state when multiple + * handles belong to the same node (start vs end endpoint, multiple + * vertices of a polygon, etc.). + */ +function makeHandleId(nodeId: AnyNodeId, payload: unknown): string { + if (payload == null) return `${nodeId}` + if (typeof payload === 'object') { + // Stable JSON serialisation of common shapes — endpoint discriminator, + // vertex index, etc. Don't try to handle arbitrarily-deep payloads. + try { + return `${nodeId}:${JSON.stringify(payload)}` + } catch { + return `${nodeId}` + } + } + return `${nodeId}:${String(payload)}` +} + +/** + * Geometry kinds that always render in the overlay pass — interactive + * handles and node labels. These need to sit above every kind's base + * geometry regardless of the owning node's z-bucket so that: + * - polygon edit handles on a selected slab don't get hidden by the + * walls / items resting on top of the slab, + * - a zone's name stays legible above the slab covering the zone, and + * - measurement labels never get clipped by structural fills. + */ +const OVERLAY_KINDS = new Set([ + 'text', + 'endpoint-handle', + 'midpoint-handle', + 'edge-handle', + 'move-handle', + 'dimension', + 'dimension-label', +]) + +/** + * Walk a `FloorplanGeometry` tree and split it into two trees: one with + * only "base" primitives (polygons, paths, fills, strokes) and one with + * only "overlay" primitives (handles, labels — see `OVERLAY_KINDS`). + * + * Groups recurse: a `kind: 'group'` is split into a base group and an + * overlay group, both carrying the same `transform` so nested rotations + * / translations apply in both passes. Empty groups collapse to `null` + * so the caller can skip emitting an `` when there's nothing to draw. + */ +function splitFloorplanOverlay(g: FloorplanGeometry): { + base: FloorplanGeometry | null + overlay: FloorplanGeometry | null +} { + if (OVERLAY_KINDS.has(g.kind)) { + return { base: null, overlay: g } + } + if (g.kind === 'group') { + const baseChildren: FloorplanGeometry[] = [] + const overlayChildren: FloorplanGeometry[] = [] + for (const child of g.children) { + const split = splitFloorplanOverlay(child) + if (split.base) baseChildren.push(split.base) + if (split.overlay) overlayChildren.push(split.overlay) + } + const base: FloorplanGeometry | null = + baseChildren.length > 0 + ? { kind: 'group', children: baseChildren, transform: g.transform } + : null + const overlay: FloorplanGeometry | null = + overlayChildren.length > 0 + ? { kind: 'group', children: overlayChildren, transform: g.transform } + : null + return { base, overlay } + } + return { base: g, overlay: null } +} + +/** + * Z-order bucket for floor-plan rendering. Lower rank = painted first = + * sits under everything with a higher rank. SVG renders in document + * order, so an earlier entry in the array ends up beneath a later one. + * + * Three buckets today: + * 0 — `zone`: conceptual area regions, always under everything else. + * 1 — `slab` / `ceiling`: the floor / ceiling surface; sits over the + * zone but under any structural / furniture geometry placed on it. + * 2 — every other kind (walls, items, shelves, columns, stairs, …): + * structure + furniture, painted on top. + * + * Sort is stable in modern JS engines, so siblings within the same + * bucket keep their DFS order (= scene tree order). + */ +function floorplanLayerRank(type: string): number { + switch (type) { + case 'zone': + return 0 + case 'slab': + case 'ceiling': + return 1 + default: + return 2 + } +} + +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) + const bKeys = Object.keys(b as Record) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!deepEqual((a as Record)[key], (b as Record)[key])) { + return false + } + } + return true + } + return false +} + +function formatGroupTransform(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 +} + +function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null { + // The registry layer lives under the floor-plan scene ``. The + // legacy panel computes the same conversion via floorplanSceneRef + + // getScreenCTM; we replicate it by walking up to the SVG owner. + const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null + const svg = target?.ownerSVGElement + if (!(svg && target)) return null + const ctm = target.getScreenCTM() + if (!ctm) return null + const point = svg.createSVGPoint() + point.x = clientX + point.y = clientY + const transformed = point.matrixTransform(ctm.inverse()) + // The floor-plan `` maps plan X/Z directly to SVG x/y (Z stored as + // the Y axis on screen — same convention as `toSvgPlanPoint`). + return [transformed.x, transformed.y] +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx deleted file mode 100644 index 9885d6fc..00000000 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +++ /dev/null @@ -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 - palette: FloorplanRoofPalette - roofEntries: FloorplanRoofEntry[] - selectedIdSet: ReadonlySet -} - -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 ( - - {segments.map(({ points, ridgeLine, segment }) => { - const isSegmentSelected = selectedIdSet.has(segment.id) - const isSegmentHighlighted = highlightedIdSet.has(segment.id) - const isSegmentActive = isSegmentSelected || isSegmentHighlighted - - return ( - - - {ridgeLine ? ( - - ) : null} - - ) - })} - - ) - })} - - ) -}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx index 20ebebbf..8fd91ecd 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx @@ -273,10 +273,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ fill={curvedAccent} key={`${stair.id}:spiral-arrow`} pointerEvents="none" - points={buildSvgArrowHeadPoints( - arrowPoint, - tangentAngle, - clamp(stair.width * 0.18, 0.12, 0.18), + points={formatSvgPolygonPoints( + buildSvgArrowHeadPoints( + arrowPoint, + tangentAngle, + clamp(stair.width * 0.18, 0.12, 0.18), + ), )} /> ) @@ -361,10 +363,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ fill={curvedAccent} key={`${stair.id}:curved-arrow`} pointerEvents="none" - points={buildSvgArrowHeadPoints( - arrowPoint, - tangentAngle, - clamp(stair.width * 0.16, 0.1, 0.16), + points={formatSvgPolygonPoints( + buildSvgArrowHeadPoints( + arrowPoint, + tangentAngle, + clamp(stair.width * 0.16, 0.1, 0.16), + ), )} /> ) diff --git a/packages/editor/src/components/editor-2d/svg-paths.ts b/packages/editor/src/components/editor-2d/svg-paths.ts index 8a3fc3d0..0d0d2c48 100644 --- a/packages/editor/src/components/editor-2d/svg-paths.ts +++ b/packages/editor/src/components/editor-2d/svg-paths.ts @@ -103,7 +103,15 @@ export function formatSvgPolygonPoints(points: Point2D[]) { return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ') } -export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) { +/** + * Three points defining an arrow head — tip + two trailing barbs. + * Returned as plain `Point2D` objects so consumers can either feed them + * straight into `formatSvgPolygonPoints` (for SVG `points=""`) or push + * them onto a `FloorplanGeometry.polygon.points` array. Mixing both + * downstream paths through a string-returning helper was awkward — see + * `nodes/src/stair/floorplan.ts` which needs the points as objects. + */ +export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number): Point2D[] { const left = { x: point.x - size * Math.cos(angle - Math.PI / 6), y: point.y - size * Math.sin(angle - Math.PI / 6), @@ -113,7 +121,7 @@ export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: num y: point.y - size * Math.sin(angle + Math.PI / 6), } - return formatSvgPolygonPoints([point, left, right]) + return [point, left, right] } export { toSvgPoint, toSvgX, toSvgY } diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 7392da7b..5050ee4a 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -7,7 +7,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer' +import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 2b0074fd..c86c3d08 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -4,8 +4,8 @@ import '../../three-types' import { type AnyNode, type AnyNodeId, - type ElevatorNode, type ElevatorDoorSide, + type ElevatorNode, emitter, getElevatorCabCenterZ, getElevatorCabDepth, @@ -18,10 +18,10 @@ import { getElevatorShaftWidth, getResolvedElevatorDoorStyle, openElevatorDoor, + requestElevatorLevel, resolveElevatorBuildingLevels, resolveElevatorDispatchTarget, resolveElevatorServiceLevels, - requestElevatorLevel, sceneRegistry, useInteractive, useScene, @@ -352,7 +352,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { const nodes = useScene.getState().nodes const meshes: ElevatorColliderMesh[] = [] - for (const elevatorId of sceneRegistry.byType.elevator) { + for (const elevatorId of sceneRegistry.byType.elevator!) { const typedElevatorId = elevatorId as AnyNodeId const node = nodes[typedElevatorId] if (node?.type !== 'elevator' || node.visible === false) continue @@ -585,7 +585,7 @@ export const FirstPersonControls = () => { let closestDoorId: AnyNodeId | null = null let closestDistance = DOOR_INTERACTION_DISTANCE - for (const doorId of sceneRegistry.byType.door) { + for (const doorId of sceneRegistry.byType.door!) { const node = nodes[doorId as AnyNodeId] if (node?.type !== 'door') continue if (node.openingKind === 'opening') continue @@ -683,7 +683,7 @@ export const FirstPersonControls = () => { let closestWindowId: AnyNodeId | null = null let closestDistance = DOOR_INTERACTION_DISTANCE - for (const windowId of sceneRegistry.byType.window) { + for (const windowId of sceneRegistry.byType.window!) { const node = nodes[windowId as AnyNodeId] if (node?.type !== 'window') continue if (node.openingKind === 'opening') continue @@ -713,7 +713,7 @@ export const FirstPersonControls = () => { let closestTarget: FirstPersonInteractableTarget | null = null let closestDistance = DOOR_INTERACTION_DISTANCE - for (const elevatorId of sceneRegistry.byType.elevator) { + for (const elevatorId of sceneRegistry.byType.elevator!) { const typedElevatorId = elevatorId as AnyNodeId const node = nodes[typedElevatorId] if (node?.type !== 'elevator') continue @@ -1088,11 +1088,11 @@ export const FirstPersonControls = () => { const elevatorIds = activeRide ? [ activeRide.elevatorId, - ...Array.from(sceneRegistry.byType.elevator).filter( + ...Array.from(sceneRegistry.byType.elevator!).filter( (elevatorId) => elevatorId !== activeRide.elevatorId, ), ] - : Array.from(sceneRegistry.byType.elevator) + : Array.from(sceneRegistry.byType.elevator!) for (const elevatorId of elevatorIds) { const typedElevatorId = elevatorId as AnyNodeId diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index fa5b563f..f977aab8 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -176,7 +176,7 @@ function buildRegisteredNodeTypeLookup() { const nodeTypes = new Map() for (const type of COLLIDER_NODE_TYPES) { - for (const nodeId of sceneRegistry.byType[type]) { + for (const nodeId of sceneRegistry.byType[type]!) { nodeTypes.set(nodeId, type) } } @@ -238,7 +238,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider } for (const type of COLLIDER_NODE_TYPES) { - for (const nodeId of sceneRegistry.byType[type]) { + for (const nodeId of sceneRegistry.byType[type]!) { if (shouldSkipColliderNode(nodeId, type)) continue const root = sceneRegistry.nodes.get(nodeId) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index b9fb6bda..20ac4b20 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -10,6 +10,8 @@ import { FenceNode, generateId, ItemNode, + isRegistrySelectable, + nodeRegistry, RoofSegmentNode, type SlabNode, SpawnNode, @@ -78,7 +80,12 @@ export function FloatingActionMenu() { // Subscribe just to the selected node so unrelated scene updates do not // re-render this menu. const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null)) - const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false + // ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any + // NodeDefinition with `capabilities.selectable`) get the floating menu + // by default too. Phase 4 collapses these into a single registry check. + const isValidType = node + ? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type) + : false // Boolean selector, only re-renders when curving availability actually flips. const canCurveSelectedWall = useScene((s) => { @@ -195,7 +202,11 @@ export function FloatingActionMenu() { node.type === 'roof' || node.type === 'roof-segment' || node.type === 'stair' || - node.type === 'stair-segment' + node.type === 'stair-segment' || + // Registry-driven kinds default to movable; MoveTool dispatches them + // to MoveRegistryNodeTool. Phase 4 reads `capabilities.movable` to + // gate this instead of the unconditional OR. + isRegistrySelectable(node.type) ) { setMovingNode(node as any) } @@ -289,6 +300,16 @@ export function FloatingActionMenu() { } else if (node.type === 'spawn') { duplicate = SpawnNode.parse(duplicateInfo) } + + // Registry-driven fallback: any kind with a NodeDefinition can be + // duplicated through its schema's parse(). Future built-in kinds + // get duplicate for free. + if (!duplicate) { + const def = nodeRegistry.get(node.type) + if (def) { + duplicate = def.schema.parse(duplicateInfo) as AnyNode + } + } } catch (error) { console.error('Failed to parse duplicate', error) useScene.temporal.getState().resume() @@ -331,6 +352,19 @@ export function FloatingActionMenu() { } // Duplicate children for stair nodes + } else if (nodeRegistry.has(duplicate.type)) { + // Registry-driven kinds: offset the position slightly so the + // duplicate doesn't overlap exactly, then create + hand to the + // move tool. Mirrors the roof-segment / stair-segment behavior. + if ('position' in duplicate && Array.isArray((duplicate as any).position)) { + const pos = (duplicate as { position: [number, number, number] }).position + ;(duplicate as { position: [number, number, number] }).position = [ + pos[0] + 1, + pos[1], + pos[2] + 1, + ] + } + useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) } if ( duplicate.type === 'item' || @@ -342,7 +376,10 @@ export function FloatingActionMenu() { duplicate.type === 'door' || duplicate.type === 'roof-segment' || duplicate.type === 'spawn' || - duplicate.type === 'stair-segment' + duplicate.type === 'stair-segment' || + // Registry-driven kinds get picked up by MoveTool's generic + // fallback (MoveRegistryNodeTool) so the user can reposition. + nodeRegistry.has(duplicate.type) ) { setMovingNode(duplicate as any) } else if (duplicate.type === 'stair') { diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 6ceb9506..96af36dc 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -8,7 +8,7 @@ import { type CeilingNode, type ColumnNode, calculateLevelMiters, - DoorNode, + type DoorNode, type ElevatorNode, emitter, type FenceNode, @@ -16,20 +16,17 @@ import { type GuideNode, getRenderableSlabPolygon, getWallChordFrame, - getWallCurveFrameAt, getWallCurveLength, - getWallMidpointHandlePoint, getWallPlanFootprint, type ItemNode, - ItemNode as ItemNodeSchema, isCurvedWall, type LevelNode, loadAssetUrl, + nodeRegistry, normalizeWallCurveOffset, type Point2D, type RoofNode, type RoofSegmentNode, - resolveElevatorServiceLevelIds, type SiteNode, SlabNode, type SpawnNode, @@ -43,9 +40,8 @@ import { useLiveNodeOverrides, useLiveTransforms, useScene, - WallNode as WallNodeSchema, type WallNode, - WindowNode, + type WindowNode, ZoneNode as ZoneNodeSchema, type ZoneNode as ZoneNodeType, } from '@pascal-app/core' @@ -72,25 +68,21 @@ import { type FloorplanNodeTransform as SharedFloorplanNodeTransform, } from '../../lib/floorplan' import { guideEmitter } from '../../lib/guide-events' -import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { sfxEmitter } from '../../lib/sfx-bus' -import { duplicateStairSubtree } from '../../lib/stair-duplication' import { cn } from '../../lib/utils' import type { GuideUiState } from '../../store/use-editor' import useEditor from '../../store/use-editor' -import { FloorplanActionMenuLayer as Editor2dFloorplanActionMenuLayer } from '../editor-2d/floorplan-action-menu-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' +import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers' +import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu' +import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay' import { - FloorplanDuplicateHotkey, - FloorplanSiteKeyHandler, -} from '../editor-2d/floorplan-hotkey-handlers' + type FloorplanRenderContextValue, + FloorplanRenderProvider, +} from '../editor-2d/floorplan-render-context' import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer' import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer' -import { - FloorplanMeasurementsLayer, - type LinearMeasurementOverlay, -} from '../editor-2d/renderers/floorplan-measurements-layer' -import { FloorplanRoofLayer } from '../editor-2d/renderers/floorplan-roof-layer' +import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer' import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer' import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' @@ -165,47 +157,16 @@ const FLOORPLAN_MARQUEE_OUTLINE_WIDTH = 0.055 const FLOORPLAN_MARQUEE_GLOW_WIDTH = 0.14 const FLOORPLAN_HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)' const FLOORPLAN_WALL_HIT_STROKE_WIDTH = 18 -const FLOORPLAN_WALL_HOVER_GLOW_STROKE_WIDTH = 18 -const FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH = 8 -const FLOORPLAN_ITEM_HOVER_GLOW_STROKE_WIDTH = 6 -const FLOORPLAN_ITEM_HOVER_RING_STROKE_WIDTH = 2 const FLOORPLAN_WALL_STROKE_WIDTH = '1' -const FLOORPLAN_SELECTED_WALL_STROKE_WIDTH = '1.5' const FLOORPLAN_OPENING_HIT_STROKE_WIDTH = 16 +const noopFloorplanStairHandler = () => {} const FLOORPLAN_OPENING_STROKE_WIDTH = 0.05 -const FLOORPLAN_OPENING_DETAIL_STROKE_WIDTH = 0.02 -const FLOORPLAN_OPENING_DASHED_STROKE_WIDTH = 0.02 const FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH = 18 const FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH = 16 const FLOORPLAN_ENDPOINT_HOVER_RING_STROKE_WIDTH = 7 const FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX = 4 -const FLOORPLAN_MEASUREMENT_OFFSET = 0.46 -const FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT = 0.08 -const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_WIDTH = 0 -const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY = 0 -const FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE = 0.15 -const FLOORPLAN_SLAB_LABEL_FONT_SIZE = 0.2 -const FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH = 0 -const FLOORPLAN_MEASUREMENT_LABEL_GAP = 0.56 -const FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING = 0.14 -const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.34 -const FLOORPLAN_WALL_INNER_MEASUREMENT_OFFSET = 0.24 -const FLOORPLAN_WALL_OUTER_MEASUREMENT_STROKE = 'rgba(59, 130, 246, 0.95)' -const FLOORPLAN_WALL_OUTER_MEASUREMENT_TEXT = 'rgba(37, 99, 235, 0.98)' -const FLOORPLAN_WALL_OUTER_MEASUREMENT_EXTENSION = 'rgba(96, 165, 250, 0.9)' -const FLOORPLAN_WALL_INNER_MEASUREMENT_STROKE = 'rgba(96, 165, 250, 0.95)' -const FLOORPLAN_WALL_INNER_MEASUREMENT_TEXT = 'rgba(59, 130, 246, 0.98)' -const FLOORPLAN_WALL_INNER_MEASUREMENT_EXTENSION = 'rgba(147, 197, 253, 0.9)' -const FLOORPLAN_OPENING_MEASUREMENT_STROKE = 'rgba(249, 115, 22, 0.98)' -const FLOORPLAN_OPENING_MEASUREMENT_TEXT = 'rgba(234, 88, 12, 0.98)' -const FLOORPLAN_OPENING_MEASUREMENT_EXTENSION = 'rgba(251, 146, 60, 0.9)' -const FLOORPLAN_ITEM_DIMENSION_OFFSET = 0.24 -const FLOORPLAN_ITEM_CLEARANCE_MAX_DISTANCE = 12 -const FLOORPLAN_ITEM_CLEARANCE_MIN_DISTANCE = 0.05 -const FLOORPLAN_ITEM_CLEARANCE_EDGE_PARALLEL_THRESHOLD = 0.65 const FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING = 60 const FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y = 56 -const FLOORPLAN_ACTION_MENU_OFFSET_Y = 10 const FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y = 1.5 // Match the guide plane footprint used in the 3D renderer so the 2D overlay aligns. @@ -219,18 +180,7 @@ const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92 const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48 const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45 const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1 -const FLOORPLAN_TRACE_SURFACE_FILL_OPACITY = 0.08 -const FLOORPLAN_TRACE_STRUCTURE_FILL_OPACITY = 0.22 -const FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY = 0.34 const FLOORPLAN_SITE_COLOR = '#10b981' -const FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH = FLOORPLAN_OPENING_STROKE_WIDTH / 2 -const FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH = FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH * 0.7 -const FLOORPLAN_SPAWN_RING_RADIUS = 0.34 -const FLOORPLAN_SPAWN_RING_STROKE_WIDTH = 0.08 -const FLOORPLAN_SPAWN_HIT_RADIUS = 0.62 -const FLOORPLAN_SPAWN_ARROW_POINTS = '0,-0.62 -0.19,-0.2 0.19,-0.2' -const FLOORPLAN_SPAWN_BODY_WIDTH = 0.3 -const FLOORPLAN_SPAWN_BODY_HEIGHT = 0.46 const FLOORPLAN_VIEW_ROTATION_DEG = 90 type FloorplanViewport = { centerX: number @@ -425,92 +375,6 @@ type WallCurveDraft = { curveOffset: number } -type SlabBoundaryDraft = { - slabId: SlabNode['id'] - polygon: WallPlanPoint[] - visualOffsets?: Point2D[] -} - -type SlabHoleBoundaryDraft = { - slabId: SlabNode['id'] - holeIndex: number - polygon: WallPlanPoint[] -} - -type SlabVertexDragState = { - pointerId: number - slabId: SlabNode['id'] - mode?: 'vertex' | 'edge' - vertexIndex: number - visualOffset: Point2D - edgeIndex?: number - edgeNormal?: WallPlanPoint - initialPlanPoint?: WallPlanPoint - initialPolygon?: WallPlanPoint[] -} - -type SlabHoleVertexDragState = { - pointerId: number - slabId: SlabNode['id'] - holeIndex: number - mode?: 'vertex' | 'edge' - vertexIndex: number - edgeIndex?: number - edgeNormal?: WallPlanPoint - initialPlanPoint?: WallPlanPoint - initialPolygon?: WallPlanPoint[] -} - -type SlabHoleMoveDraft = { - slabId: SlabNode['id'] - holeIndex: number - polygon: WallPlanPoint[] - originalPolygon: WallPlanPoint[] - startPlanPoint: WallPlanPoint -} - -type CeilingBoundaryDraft = { - ceilingId: CeilingNode['id'] - polygon: WallPlanPoint[] -} - -type CeilingVertexDragState = { - pointerId: number - ceilingId: CeilingNode['id'] - mode?: 'vertex' | 'edge' - vertexIndex: number - edgeIndex?: number - edgeNormal?: WallPlanPoint - initialPlanPoint?: WallPlanPoint - initialPolygon?: WallPlanPoint[] -} - -type CeilingHoleBoundaryDraft = { - ceilingId: CeilingNode['id'] - holeIndex: number - polygon: WallPlanPoint[] -} - -type CeilingHoleVertexDragState = { - pointerId: number - ceilingId: CeilingNode['id'] - holeIndex: number - mode?: 'vertex' | 'edge' - vertexIndex: number - edgeIndex?: number - edgeNormal?: WallPlanPoint - initialPlanPoint?: WallPlanPoint - initialPolygon?: WallPlanPoint[] -} - -type CeilingHoleMoveDraft = { - ceilingId: CeilingNode['id'] - holeIndex: number - polygon: WallPlanPoint[] - originalPolygon: WallPlanPoint[] - startPlanPoint: WallPlanPoint -} - type SiteBoundaryDraft = { siteId: SiteNode['id'] polygon: WallPlanPoint[] @@ -522,17 +386,6 @@ type SiteVertexDragState = { vertexIndex: number } -type ZoneBoundaryDraft = { - zoneId: ZoneNodeType['id'] - polygon: WallPlanPoint[] -} - -type ZoneVertexDragState = { - pointerId: number - zoneId: ZoneNodeType['id'] - vertexIndex: number -} - type WallPolygonEntry = { wall: WallNode polygon: Point2D[] @@ -564,37 +417,6 @@ type SlabPolygonEntry = { path: string } -function getSlabHandlePolygon(entry: SlabPolygonEntry) { - return entry.visualPolygon.length === entry.polygon.length ? entry.visualPolygon : entry.polygon -} - -function getSlabVisualOffsets(entry: SlabPolygonEntry): Point2D[] { - const handlePolygon = getSlabHandlePolygon(entry) - - return entry.polygon.map((point) => { - const handlePoint = - handlePolygon.length > 0 - ? handlePolygon[getClosestPolygonVertexIndex(point, handlePolygon)] - : point - - return { - x: (handlePoint?.x ?? point.x) - point.x, - y: (handlePoint?.y ?? point.y) - point.y, - } - }) -} - -function getDraftSlabVisualPolygon(draft: SlabBoundaryDraft): Point2D[] { - return draft.polygon.map(([x, y], index) => { - const offset = draft.visualOffsets?.[index] - - return { - x: x + (offset?.x ?? 0), - y: y + (offset?.y ?? 0), - } - }) -} - type CeilingPolygonEntry = { ceiling: CeilingNode polygon: Point2D[] @@ -1436,42 +1258,6 @@ function crossPlanVectors(a: Point2D, b: Point2D) { return a.x * b.y - a.y * b.x } -function getRaySegmentIntersection( - origin: Point2D, - direction: Point2D, - segmentStart: Point2D, - segmentEnd: Point2D, -) { - const segmentVector = { - x: segmentEnd.x - segmentStart.x, - y: segmentEnd.y - segmentStart.y, - } - const denominator = crossPlanVectors(direction, segmentVector) - - if (Math.abs(denominator) <= 1e-9) { - return null - } - - const delta = { - x: segmentStart.x - origin.x, - y: segmentStart.y - origin.y, - } - const rayDistance = crossPlanVectors(delta, segmentVector) / denominator - const segmentT = crossPlanVectors(delta, direction) / denominator - - if (rayDistance < 0 || segmentT < 0 || segmentT > 1) { - return null - } - - return { - point: { - x: origin.x + direction.x * rayDistance, - y: origin.y + direction.y * rayDistance, - }, - rayDistance, - } -} - function getViewportBounds(): ViewportBounds { if (typeof window === 'undefined') { return { @@ -2478,592 +2264,6 @@ function formatArea( ) } -function getWallMeasurementOverlay( - wall: WallNode, - centerX: number, - centerZ: number, - unit: 'metric' | 'imperial', - metersPerUnit: number | null = null, -): LinearMeasurementOverlay | null { - const dx = wall.end[0] - wall.start[0] - const dz = wall.end[1] - wall.start[1] - const length = getWallCurveLength(wall) - - if (length < 0.1) { - return null - } - - const nx = -dz / length - const nz = dx / length - const midX = (wall.start[0] + wall.end[0]) / 2 - const midZ = (wall.start[1] + wall.end[1]) / 2 - const cx = midX - centerX - const cz = midZ - centerZ - const dot = cx * nx + cz * nz - const outX = dot >= 0 ? nx : -nx - const outZ = dot >= 0 ? nz : -nz - const label = formatMeasurement(length, unit, metersPerUnit) - const dimensionLine = { - x1: toSvgX(wall.start[0] + outX * FLOORPLAN_MEASUREMENT_OFFSET), - y1: toSvgY(wall.start[1] + outZ * FLOORPLAN_MEASUREMENT_OFFSET), - x2: toSvgX(wall.end[0] + outX * FLOORPLAN_MEASUREMENT_OFFSET), - y2: toSvgY(wall.end[1] + outZ * FLOORPLAN_MEASUREMENT_OFFSET), - } - - const extensionStart = { - x1: toSvgX(wall.start[0]), - y1: toSvgY(wall.start[1]), - x2: toSvgX( - wall.start[0] + - outX * (FLOORPLAN_MEASUREMENT_OFFSET + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT), - ), - y2: toSvgY( - wall.start[1] + - outZ * (FLOORPLAN_MEASUREMENT_OFFSET + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT), - ), - } - - const extensionEnd = { - x1: toSvgX(wall.end[0]), - y1: toSvgY(wall.end[1]), - x2: toSvgX( - wall.end[0] + - outX * (FLOORPLAN_MEASUREMENT_OFFSET + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT), - ), - y2: toSvgY( - wall.end[1] + - outZ * (FLOORPLAN_MEASUREMENT_OFFSET + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT), - ), - } - - const svgDx = dimensionLine.x2 - dimensionLine.x1 - const svgDy = dimensionLine.y2 - dimensionLine.y1 - const svgLength = Math.hypot(svgDx, svgDy) - let labelAngleDeg = (Math.atan2(svgDy, svgDx) * 180) / Math.PI - - if (labelAngleDeg > 90) { - labelAngleDeg -= 180 - } else if (labelAngleDeg <= -90) { - labelAngleDeg += 180 - } - - if (svgLength < 1e-6) { - return null - } - - const dirSvgX = svgDx / svgLength - const dirSvgY = svgDy / svgLength - const labelGapHalf = Math.min( - FLOORPLAN_MEASUREMENT_LABEL_GAP / 2, - Math.max(0, svgLength / 2 - FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING), - ) - const labelX = (dimensionLine.x1 + dimensionLine.x2) / 2 - const labelY = (dimensionLine.y1 + dimensionLine.y2) / 2 - const dimensionLineStart = { - x1: dimensionLine.x1, - y1: dimensionLine.y1, - x2: labelX - dirSvgX * labelGapHalf, - y2: labelY - dirSvgY * labelGapHalf, - } - const dimensionLineEnd = { - x1: labelX + dirSvgX * labelGapHalf, - y1: labelY + dirSvgY * labelGapHalf, - x2: dimensionLine.x2, - y2: dimensionLine.y2, - } - - return { - id: `${wall.id}:centerline`, - dimensionLineEnd, - dimensionLineStart, - extensionStart, - extensionEnd, - label, - labelX, - labelY, - labelAngleDeg, - } -} - -function getLinearMeasurementOverlay( - id: string, - start: Point2D, - end: Point2D, - label: string, - options?: { - extensionOvershoot?: number - offsetDistance?: number - offsetVector?: Point2D - }, -): LinearMeasurementOverlay | null { - const extensionOvershoot = - options?.extensionOvershoot ?? FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT - const offsetDistance = options?.offsetDistance ?? 0 - const offsetVector = options?.offsetVector - const offsetStart = - offsetVector && offsetDistance !== 0 - ? { - x: start.x + offsetVector.x * offsetDistance, - y: start.y + offsetVector.y * offsetDistance, - } - : start - const offsetEnd = - offsetVector && offsetDistance !== 0 - ? { - x: end.x + offsetVector.x * offsetDistance, - y: end.y + offsetVector.y * offsetDistance, - } - : end - const dimensionLine = { - x1: toSvgX(offsetStart.x), - y1: toSvgY(offsetStart.y), - x2: toSvgX(offsetEnd.x), - y2: toSvgY(offsetEnd.y), - } - - const svgDx = dimensionLine.x2 - dimensionLine.x1 - const svgDy = dimensionLine.y2 - dimensionLine.y1 - const svgLength = Math.hypot(svgDx, svgDy) - let labelAngleDeg = (Math.atan2(svgDy, svgDx) * 180) / Math.PI - - if (labelAngleDeg > 90) { - labelAngleDeg -= 180 - } else if (labelAngleDeg <= -90) { - labelAngleDeg += 180 - } - - if (svgLength < 1e-6) { - return null - } - - const dirSvgX = svgDx / svgLength - const dirSvgY = svgDy / svgLength - const labelGapHalf = Math.min( - FLOORPLAN_MEASUREMENT_LABEL_GAP / 2, - Math.max(0, svgLength / 2 - FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING), - ) - const labelX = (dimensionLine.x1 + dimensionLine.x2) / 2 - const labelY = (dimensionLine.y1 + dimensionLine.y2) / 2 - - return { - id, - dimensionLineStart: { - x1: dimensionLine.x1, - y1: dimensionLine.y1, - x2: labelX - dirSvgX * labelGapHalf, - y2: labelY - dirSvgY * labelGapHalf, - }, - dimensionLineEnd: { - x1: labelX + dirSvgX * labelGapHalf, - y1: labelY + dirSvgY * labelGapHalf, - x2: dimensionLine.x2, - y2: dimensionLine.y2, - }, - extensionStart: { - x1: toSvgX(start.x), - y1: toSvgY(start.y), - x2: toSvgX( - offsetVector ? start.x + offsetVector.x * (offsetDistance + extensionOvershoot) : start.x, - ), - y2: toSvgY( - offsetVector ? start.y + offsetVector.y * (offsetDistance + extensionOvershoot) : start.y, - ), - }, - extensionEnd: { - x1: toSvgX(end.x), - y1: toSvgY(end.y), - x2: toSvgX( - offsetVector ? end.x + offsetVector.x * (offsetDistance + extensionOvershoot) : end.x, - ), - y2: toSvgY( - offsetVector ? end.y + offsetVector.y * (offsetDistance + extensionOvershoot) : end.y, - ), - }, - label, - labelX, - labelY, - labelAngleDeg, - isSelected: true, - } -} - -type WallFaceLine = { - start: Point2D - end: Point2D -} - -type WallMeasurementFaceContext = { - outerFace: WallFaceLine - innerFace: WallFaceLine - outwardNormal: Point2D - inwardNormal: Point2D -} - -function getWallFaceLines( - polygon: Point2D[], - wall: WallNode, -): { left: WallFaceLine; right: WallFaceLine } | null { - if (polygon.length < 4 || isCurvedWall(wall)) { - return null - } - - const startRight = polygon[0] - const endRight = polygon[1] - const hasEndCenterPoint = pointMatchesWallPlanPoint(polygon[2], wall.end) - const endLeft = polygon[hasEndCenterPoint ? 3 : 2] - const lastPoint = polygon[polygon.length - 1] - const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start) - const startLeft = polygon[hasStartCenterPoint ? polygon.length - 2 : polygon.length - 1] - - if (!(startRight && endRight && endLeft && startLeft)) { - return null - } - - return { - left: { - start: startLeft, - end: endLeft, - }, - right: { - start: startRight, - end: endRight, - }, - } -} - -function getLineMidpoint(line: WallFaceLine): Point2D { - return { - x: (line.start.x + line.end.x) / 2, - y: (line.start.y + line.end.y) / 2, - } -} - -function getWallMeasurementFaceContext( - selectedWallEntry: WallPolygonEntry, - wallPolygons: WallPolygonEntry[], -): WallMeasurementFaceContext | null { - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const { wall } of wallPolygons) { - minX = Math.min(minX, wall.start[0], wall.end[0]) - maxX = Math.max(maxX, wall.start[0], wall.end[0]) - minY = Math.min(minY, wall.start[1], wall.end[1]) - maxY = Math.max(maxY, wall.start[1], wall.end[1]) - } - - const centerX = minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2 - const centerY = minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2 - const { wall, polygon } = selectedWallEntry - const faceLines = getWallFaceLines(polygon, wall) - - if (!faceLines) { - return null - } - - const dx = wall.end[0] - wall.start[0] - const dy = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dy) - - if (length < 1e-6) { - return null - } - - const wallMidpoint = { - x: (wall.start[0] + wall.end[0]) / 2, - y: (wall.start[1] + wall.end[1]) / 2, - } - const normal = { x: -dy / length, y: dx / length } - const fromCenter = { - x: wallMidpoint.x - centerX, - y: wallMidpoint.y - centerY, - } - const outwardNormal = - fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y } - const rightMidpoint = getLineMidpoint(faceLines.right) - const leftMidpoint = getLineMidpoint(faceLines.left) - const rightScore = - (rightMidpoint.x - wallMidpoint.x) * outwardNormal.x + - (rightMidpoint.y - wallMidpoint.y) * outwardNormal.y - const leftScore = - (leftMidpoint.x - wallMidpoint.x) * outwardNormal.x + - (leftMidpoint.y - wallMidpoint.y) * outwardNormal.y - const outerFace = rightScore >= leftScore ? faceLines.right : faceLines.left - const innerFace = outerFace === faceLines.right ? faceLines.left : faceLines.right - - return { - outerFace, - innerFace, - outwardNormal, - inwardNormal: { x: -outwardNormal.x, y: -outwardNormal.y }, - } -} - -function getAdjacentOpeningBounds( - current: { - id: OpeningNode['id'] - wallId: WallNode['id'] - startDistance: number - endDistance: number - }, - openings: OpeningPolygonEntry[], -) { - let leftBoundary: number | null = null - let rightBoundary: number | null = null - - for (const { opening } of openings) { - if (opening.parentId !== current.wallId || opening.id === current.id) { - continue - } - - const startDistance = opening.position[0] - opening.width / 2 - const endDistance = opening.position[0] + opening.width / 2 - - if ( - endDistance <= current.startDistance && - (leftBoundary === null || endDistance > leftBoundary) - ) { - leftBoundary = endDistance - } - - if ( - startDistance >= current.endDistance && - (rightBoundary === null || startDistance < rightBoundary) - ) { - rightBoundary = startDistance - } - } - - return { - leftBoundary, - rightBoundary, - } -} - -function getSelectedWallMeasurementOverlays( - selectedWallEntry: WallPolygonEntry, - wallPolygons: WallPolygonEntry[], - unit: 'metric' | 'imperial', - metersPerUnit: number | null = null, -): LinearMeasurementOverlay[] { - const { wall } = selectedWallEntry - - if (isCurvedWall(wall)) { - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const { wall: candidateWall } of wallPolygons) { - minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0]) - maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0]) - minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1]) - maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1]) - } - - const centerX = minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2 - const centerY = minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2 - const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit, metersPerUnit) - return overlay ? [overlay] : [] - } - - const faceContext = getWallMeasurementFaceContext(selectedWallEntry, wallPolygons) - if (!faceContext) { - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const { wall: candidateWall } of wallPolygons) { - minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0]) - maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0]) - minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1]) - maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1]) - } - - const centerX = minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2 - const centerY = minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2 - const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit, metersPerUnit) - return overlay ? [overlay] : [] - } - - const { outerFace, innerFace, outwardNormal, inwardNormal } = faceContext - const outerLength = Math.hypot( - outerFace.end.x - outerFace.start.x, - outerFace.end.y - outerFace.start.y, - ) - const innerLength = Math.hypot( - innerFace.end.x - innerFace.start.x, - innerFace.end.y - innerFace.start.y, - ) - const overlays: LinearMeasurementOverlay[] = [] - - if (outerLength >= 0.1) { - const overlay = getLinearMeasurementOverlay( - `${wall.id}:outer-face`, - outerFace.start, - outerFace.end, - formatMeasurement(outerLength, unit, metersPerUnit), - { - offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, - offsetVector: outwardNormal, - }, - ) - - if (overlay) { - overlays.push({ - ...overlay, - extensionStroke: FLOORPLAN_WALL_OUTER_MEASUREMENT_EXTENSION, - labelFill: FLOORPLAN_WALL_OUTER_MEASUREMENT_TEXT, - stroke: FLOORPLAN_WALL_OUTER_MEASUREMENT_STROKE, - }) - } - } - - if (innerLength >= 0.1) { - const overlay = getLinearMeasurementOverlay( - `${wall.id}:inner-face`, - innerFace.start, - innerFace.end, - formatMeasurement(innerLength, unit, metersPerUnit), - { - offsetDistance: FLOORPLAN_WALL_INNER_MEASUREMENT_OFFSET, - offsetVector: inwardNormal, - }, - ) - - if (overlay) { - overlays.push({ - ...overlay, - extensionStroke: FLOORPLAN_WALL_INNER_MEASUREMENT_EXTENSION, - labelFill: FLOORPLAN_WALL_INNER_MEASUREMENT_TEXT, - stroke: FLOORPLAN_WALL_INNER_MEASUREMENT_STROKE, - }) - } - } - - return overlays -} - -function getItemDimensionMeasurementOverlays( - itemEntry: FloorplanItemEntry, - unit: 'metric' | 'imperial', -): LinearMeasurementOverlay[] { - const itemMetadata = - typeof itemEntry.item.metadata === 'object' && - itemEntry.item.metadata !== null && - !Array.isArray(itemEntry.item.metadata) - ? (itemEntry.item.metadata as Record) - : null - - if (itemMetadata?.isTransient !== true) { - return [] - } - - const polygon = itemEntry.polygon - if (polygon.length < 4) { - return [] - } - - const centroid = polygonCentroid(polygon) - const configuredWidth = formatMeasurement( - itemEntry.item.scale[0] * itemEntry.item.asset.dimensions[0], - unit, - ) - const configuredDepth = formatMeasurement( - itemEntry.item.scale[2] * itemEntry.item.asset.dimensions[2], - unit, - ) - const buildSideOverlay = ( - id: string, - start: Point2D, - end: Point2D, - ): LinearMeasurementOverlay | null => { - const edgeVector = { - x: end.x - start.x, - y: end.y - start.y, - } - const tangent = normalizePlanVector(edgeVector) - if (!tangent) { - return null - } - - let outwardNormal: Point2D = { - x: -tangent.y, - y: tangent.x, - } - const midpoint = { - x: (start.x + end.x) / 2, - y: (start.y + end.y) / 2, - } - const centroidVector = { - x: midpoint.x - centroid.x, - y: midpoint.y - centroid.y, - } - - if (dotPlanVectors(outwardNormal, centroidVector) < 0) { - outwardNormal = { - x: -outwardNormal.x, - y: -outwardNormal.y, - } - } - - const overlay = getLinearMeasurementOverlay( - id, - start, - end, - id.includes(':width') ? configuredWidth : configuredDepth, - { - extensionOvershoot: 0, - offsetDistance: FLOORPLAN_ITEM_DIMENSION_OFFSET, - offsetVector: outwardNormal, - }, - ) - - return overlay - ? { - dashedExtensions: false, - ...overlay, - isSelected: true, - showTicks: false, - } - : null - } - - const widthCandidates: LinearMeasurementOverlay[] = [ - polygon[0] && polygon[1] - ? buildSideOverlay(`${itemEntry.item.id}:width-a`, polygon[0], polygon[1]) - : null, - polygon[2] && polygon[3] - ? buildSideOverlay(`${itemEntry.item.id}:width-b`, polygon[3], polygon[2]) - : null, - ].filter((overlay): overlay is LinearMeasurementOverlay => overlay !== null) - - const depthCandidates: LinearMeasurementOverlay[] = [ - polygon[1] && polygon[2] - ? buildSideOverlay(`${itemEntry.item.id}:depth-a`, polygon[1], polygon[2]) - : null, - polygon[0] && polygon[3] - ? buildSideOverlay(`${itemEntry.item.id}:depth-b`, polygon[0], polygon[3]) - : null, - ].filter((overlay): overlay is LinearMeasurementOverlay => overlay !== null) - - const widthOverlay = - widthCandidates.length > 0 - ? widthCandidates.reduce((best, current) => (current.labelY > best.labelY ? current : best)) - : null - const depthOverlay = - depthCandidates.length > 0 - ? depthCandidates.reduce((best, current) => (current.labelX < best.labelX ? current : best)) - : null - - return [widthOverlay, depthOverlay].filter( - (overlay): overlay is LinearMeasurementOverlay => overlay !== null, - ) -} - function getOpeningFootprint(wall: WallNode, node: WindowNode | DoorNode): Point2D[] { const [x1, z1] = wall.start const [x2, z2] = wall.end @@ -4067,2894 +3267,6 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer( ) }) -const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ - canFocusGeometry, - canSelectGeometry, - canSelectSlabs, - canSelectCeilings, - ceilingPolygons, - highlightedIdSet, - hoveredCeilingId, - hoveredSlabId, - hoveredOpeningId, - hoveredWallId, - isDeleteMode, - onCeilingDoubleClick, - onCeilingHoverChange, - onCeilingSelect, - onSlabDoubleClick, - onSlabHoverChange, - onSlabSelect, - onOpeningDoubleClick, - onOpeningHoverChange, - onOpeningPointerDown, - onOpeningSelect, - onWallClick, - onWallDoubleClick, - onWallHoverChange, - openingsPolygons, - palette, - selectedIdSet, - slabSelectionHatchId, - slabPolygons, - wallPolygons, - wallSelectionHatchId, - unit, - metersPerUnit, - isGuideTraceVisible, -}: { - canFocusGeometry: boolean - canSelectSlabs: boolean - canSelectCeilings: boolean - canSelectGeometry: boolean - ceilingPolygons: CeilingPolygonEntry[] - highlightedIdSet: ReadonlySet - hoveredCeilingId: CeilingNode['id'] | null - hoveredSlabId: SlabNode['id'] | null - hoveredOpeningId: OpeningNode['id'] | null - isDeleteMode: boolean - onCeilingDoubleClick: (ceiling: CeilingNode) => void - onCeilingHoverChange: (ceilingId: CeilingNode['id'] | null) => void - onCeilingSelect: (ceilingId: CeilingNode['id'], event: ReactMouseEvent) => void - onSlabDoubleClick: (slab: SlabNode) => void - onSlabHoverChange: (slabId: SlabNode['id'] | null) => void - onSlabSelect: (slabId: SlabNode['id'], event: ReactMouseEvent) => void - onOpeningDoubleClick: (opening: OpeningNode) => void - onOpeningHoverChange: (openingId: OpeningNode['id'] | null) => void - onOpeningPointerDown: (openingId: OpeningNode['id'], event: ReactPointerEvent) => void - onOpeningSelect: (openingId: OpeningNode['id'], event: ReactMouseEvent) => void - hoveredWallId: WallNode['id'] | null - onWallClick: (wall: WallNode, event: ReactMouseEvent) => void - onWallDoubleClick: (wall: WallNode, event: ReactMouseEvent) => void - onWallHoverChange: (wallId: WallNode['id'] | null) => void - openingsPolygons: OpeningPolygonEntry[] - palette: FloorplanPalette - selectedIdSet: ReadonlySet - slabSelectionHatchId: string - slabPolygons: SlabPolygonEntry[] - wallPolygons: WallPolygonEntry[] - wallSelectionHatchId: string - unit: 'metric' | 'imperial' - metersPerUnit: number | null - isGuideTraceVisible: boolean -}) { - const selectedWallEntries = wallPolygons.filter(({ wall }) => selectedIdSet.has(wall.id)) - const wallMeasurements = - selectedIdSet.size === 1 && selectedWallEntries.length === 1 - ? getSelectedWallMeasurementOverlays( - selectedWallEntries[0]!, - wallPolygons, - unit, - metersPerUnit, - ) - : [] - - return ( - <> - {slabPolygons.map(({ slab, polygon, visualPolygon, visualHoles, path }) => { - const isSelected = selectedIdSet.has(slab.id) - const isHighlighted = highlightedIdSet.has(slab.id) - const isDeleteHovered = isDeleteMode && hoveredSlabId === slab.id - const showSelectedSlabStyle = isSelected || isHighlighted - const slabBorderStroke = isDeleteHovered - ? palette.deleteStroke - : showSelectedSlabStyle - ? palette.selectedSlabStroke - : palette.slabStroke - const slabBorderWidth = showSelectedSlabStyle ? '1.2' : '1' - const slabFillOpacity = isDeleteHovered - ? 1 - : isGuideTraceVisible - ? showSelectedSlabStyle - ? FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY - : FLOORPLAN_TRACE_STRUCTURE_FILL_OPACITY - : 1 - let slabLabel = null - - if (isSelected) { - const { area, centroid } = getSlabArea(visualPolygon, visualHoles) - if (area > 0) { - slabLabel = ( - - {formatArea(area, unit, metersPerUnit)} - - ) - } - } - - return ( - - - { - event.stopPropagation() - onSlabSelect(slab.id, event) - } - : undefined - } - onDoubleClick={ - canFocusGeometry - ? (event) => { - event.stopPropagation() - onSlabDoubleClick(slab) - } - : undefined - } - onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined} - onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined} - opacity={slabFillOpacity} - pointerEvents={canSelectSlabs ? undefined : 'none'} - stroke="none" - style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined} - /> - {isSelected && !isDeleteHovered ? ( - - ) : null} - - {slabLabel} - - ) - })} - - {ceilingPolygons.map(({ ceiling, path }) => { - const isSelected = selectedIdSet.has(ceiling.id) - const isHighlighted = highlightedIdSet.has(ceiling.id) - const isDeleteHovered = isDeleteMode && hoveredCeilingId === ceiling.id - const showSelectedCeilingStyle = isSelected || isHighlighted - const ceilingBorderStroke = isDeleteHovered - ? palette.deleteStroke - : showSelectedCeilingStyle - ? palette.selectedCeilingStroke - : palette.ceilingStroke - const ceilingBorderWidth = showSelectedCeilingStyle ? '1.2' : '1' - const ceilingFillOpacity = isDeleteHovered - ? 1 - : isGuideTraceVisible - ? showSelectedCeilingStyle - ? FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY - : FLOORPLAN_TRACE_STRUCTURE_FILL_OPACITY - : 1 - - return ( - - { - event.stopPropagation() - onCeilingSelect(ceiling.id, event) - } - : undefined - } - onDoubleClick={ - canFocusGeometry - ? (event) => { - event.stopPropagation() - onCeilingDoubleClick(ceiling) - } - : undefined - } - onPointerEnter={ - canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined - } - onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined} - opacity={ceilingFillOpacity} - pointerEvents={canSelectCeilings ? undefined : 'none'} - stroke="none" - style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined} - /> - {isSelected && !isDeleteHovered ? ( - - ) : null} - - - ) - })} - - {wallPolygons.map(({ wall, polygon, points }) => { - const isSelected = selectedIdSet.has(wall.id) - const isHighlighted = highlightedIdSet.has(wall.id) - const isHovered = canSelectGeometry && hoveredWallId === wall.id - const isDeleteHovered = isDeleteMode && isHovered - const showSelectedWallChrome = isSelected || isHighlighted - const wallStroke = isDeleteHovered - ? palette.deleteStroke - : showSelectedWallChrome - ? palette.selectedStroke - : palette.wallStroke - - return ( - onWallHoverChange(wall.id) : undefined} - onPointerLeave={canSelectGeometry ? () => onWallHoverChange(null) : undefined} - > - {canSelectGeometry && ( - { - event.stopPropagation() - onWallClick(wall, event) - }} - onDoubleClick={(event) => { - event.stopPropagation() - onWallDoubleClick(wall, event) - }} - pointerEvents="stroke" - stroke="transparent" - strokeLinecap="round" - strokeWidth={FLOORPLAN_WALL_HIT_STROKE_WIDTH} - style={{ cursor: EDITOR_CURSOR }} - vectorEffect="non-scaling-stroke" - x1={toSvgX(wall.start[0])} - x2={toSvgX(wall.end[0])} - y1={toSvgY(wall.start[1])} - y2={toSvgY(wall.end[1])} - /> - )} - { - event.stopPropagation() - onWallClick(wall, event) - } - : undefined - } - onDoubleClick={ - canSelectGeometry - ? (event) => { - event.stopPropagation() - onWallDoubleClick(wall, event) - } - : undefined - } - points={points} - stroke={wallStroke} - strokeOpacity={1} - strokeWidth={ - showSelectedWallChrome - ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH - : FLOORPLAN_WALL_STROKE_WIDTH - } - style={{ cursor: EDITOR_CURSOR }} - vectorEffect="non-scaling-stroke" - /> - {isSelected && !isDeleteHovered ? ( - - ) : null} - - ) - })} - - {openingsPolygons.map(({ opening, polygon, points }) => { - const isSelected = selectedIdSet.has(opening.id) - const isSelectionHighlighted = highlightedIdSet.has(opening.id) - const isHovered = canSelectGeometry && hoveredOpeningId === opening.id - const isDeleteHovered = isDeleteMode && isHovered - const centerLine = getOpeningCenterLine(polygon) - - if (opening.type === 'window') { - if (polygon.length < 4) return null - if (!centerLine) return null - const [p1, p2, p3, p4] = polygon - const tangentDx = p2!.x - p1!.x - const tangentDy = p2!.y - p1!.y - const tangentLength = Math.hypot(tangentDx, tangentDy) - const normalDx = p4!.x - p1!.x - const normalDy = p4!.y - p1!.y - const normalLength = Math.hypot(normalDx, normalDy) - - if (tangentLength < 1e-6 || normalLength < 1e-6) return null - - const tangentX = tangentDx / tangentLength - const tangentY = tangentDy / tangentLength - const normalX = normalDx / normalLength - const normalY = normalDy / normalLength - const tangentInset = Math.min(tangentLength * 0.08, 0.12) - const normalInset = Math.min(normalLength * 0.22, 0.07) - const insetInnerStart = { - x: p1!.x + tangentX * tangentInset + normalX * normalInset, - y: p1!.y + tangentY * tangentInset + normalY * normalInset, - } - const insetInnerEnd = { - x: p2!.x - tangentX * tangentInset + normalX * normalInset, - y: p2!.y - tangentY * tangentInset + normalY * normalInset, - } - const insetOuterEnd = { - x: p3!.x - tangentX * tangentInset - normalX * normalInset, - y: p3!.y - tangentY * tangentInset - normalY * normalInset, - } - const insetOuterStart = { - x: p4!.x + tangentX * tangentInset - normalX * normalInset, - y: p4!.y + tangentY * tangentInset - normalY * normalInset, - } - const centerStart = { - x: (insetInnerStart.x + insetOuterStart.x) / 2, - y: (insetInnerStart.y + insetOuterStart.y) / 2, - } - const centerEnd = { - x: (insetInnerEnd.x + insetOuterEnd.x) / 2, - y: (insetInnerEnd.y + insetOuterEnd.y) / 2, - } - const symbolStroke = - isSelected || isSelectionHighlighted ? '#f97316' : 'rgba(31, 41, 55, 0.92)' - const symbolFill = 'rgba(255, 255, 255, 0.96)' - const symbolStrokeWidth = isSelected || isSelectionHighlighted ? '1.9' : '1.25' - const innerStrokeWidth = isSelected || isSelectionHighlighted ? '1.3' : '0.9' - const detailStrokeWidth = isSelected || isSelectionHighlighted ? '1.05' : '0.75' - const markerX = (p1!.x + p2!.x + p3!.x + p4!.x) / 4 - const markerY = (p1!.y + p2!.y + p3!.y + p4!.y) / 4 - const windowOpeningShape = opening.openingShape ?? 'rectangle' - - if (opening.openingKind === 'opening') { - const detailInset = Math.min(tangentLength * 0.14, 0.18) - const detailStart = { - x: centerLine.start.x + tangentX * detailInset, - y: centerLine.start.y + tangentY * detailInset, - } - const detailEnd = { - x: centerLine.end.x - tangentX * detailInset, - y: centerLine.end.y - tangentY * detailInset, - } - const detailControl = { - x: (detailStart.x + detailEnd.x) / 2 + normalX * normalLength * 0.34, - y: (detailStart.y + detailEnd.y) / 2 + normalY * normalLength * 0.34, - } - const detailPath = - windowOpeningShape === 'rectangle' - ? null - : `M ${toSvgX(detailStart.x)} ${toSvgY(detailStart.y)} Q ${toSvgX(detailControl.x)} ${toSvgY(detailControl.y)} ${toSvgX(detailEnd.x)} ${toSvgY(detailEnd.y)}` - - return ( - { - event.stopPropagation() - onOpeningSelect(opening.id, event) - } - : undefined - } - onDoubleClick={ - canFocusGeometry - ? (event) => { - event.stopPropagation() - onOpeningDoubleClick(opening) - } - : undefined - } - onPointerDown={ - canFocusGeometry && isSelected - ? (event) => { - if (event.button === 0) { - onOpeningPointerDown(opening.id, event) - } - } - : undefined - } - onPointerEnter={ - canSelectGeometry - ? () => { - onWallHoverChange(null) - onOpeningHoverChange(opening.id) - } - : undefined - } - onPointerLeave={canSelectGeometry ? () => onOpeningHoverChange(null) : undefined} - style={{ cursor: EDITOR_CURSOR }} - > - {canSelectGeometry && ( - - )} - - {detailPath ? ( - - ) : ( - - )} - {isSelected ? ( - <> - - - - - ) : null} - - ) - } - - return ( - { - event.stopPropagation() - onOpeningSelect(opening.id, event) - } - : undefined - } - onDoubleClick={ - canFocusGeometry - ? (event) => { - event.stopPropagation() - onOpeningDoubleClick(opening) - } - : undefined - } - onPointerDown={ - canFocusGeometry && isSelected - ? (event) => { - if (event.button === 0) { - onOpeningPointerDown(opening.id, event) - } - } - : undefined - } - onPointerEnter={ - canSelectGeometry - ? () => { - onWallHoverChange(null) - onOpeningHoverChange(opening.id) - } - : undefined - } - onPointerLeave={canSelectGeometry ? () => onOpeningHoverChange(null) : undefined} - style={{ cursor: EDITOR_CURSOR }} - > - {canSelectGeometry && ( - - )} - - - - {[0.25, 0.5, 0.75].map((ratio) => { - const topPoint = { - x: insetInnerStart.x + (insetInnerEnd.x - insetInnerStart.x) * ratio, - y: insetInnerStart.y + (insetInnerEnd.y - insetInnerStart.y) * ratio, - } - const bottomPoint = { - x: insetOuterStart.x + (insetOuterEnd.x - insetOuterStart.x) * ratio, - y: insetOuterStart.y + (insetOuterEnd.y - insetOuterStart.y) * ratio, - } - const midPoint = { - x: (topPoint.x + bottomPoint.x) / 2, - y: (topPoint.y + bottomPoint.y) / 2, - } - const mullionHalf = normalLength * 0.18 - - return ( - - ) - })} - {isSelected ? ( - <> - - - - - ) : null} - - ) - } - - if (opening.type === 'door') { - if (polygon.length < 4) return null - const [p1, p2, p3, p4] = polygon - const svgP1 = toSvgPoint(p1!) - const svgP2 = toSvgPoint(p2!) - const svgP3 = toSvgPoint(p3!) - const svgP4 = toSvgPoint(p4!) - const centerX = (p1!.x + p2!.x + p3!.x + p4!.x) / 4 - const centerY = (p1!.y + p2!.y + p3!.y + p4!.y) / 4 - - const dirX = svgP2.x - svgP1.x - const dirY = svgP2.y - svgP1.y - const len = Math.sqrt(dirX * dirX + dirY * dirY) - if (len < 1e-6) return null - - const cx = toSvgX(centerX) - const cy = toSvgY(centerY) - const nx = dirX / len - const ny = dirY / len - const px = -ny - const py = nx - - const isPlanFlipped = isOpeningPlanFlipped(opening.rotation) - const baseHingesSide = opening.hingesSide ?? 'left' - const baseSwingDirection = opening.swingDirection ?? 'inward' - const hingesSide = isPlanFlipped ? getFlippedHingesSide(baseHingesSide) : baseHingesSide - const swingDirection = isPlanFlipped - ? getFlippedSwingDirection(baseSwingDirection) - : baseSwingDirection - const swingAngle = Math.max(0, Math.min(Math.PI / 2, opening.swingAngle ?? 0)) - const width = opening.width - const sweepFlag = - hingesSide === 'left' - ? swingDirection === 'inward' - ? 0 - : 1 - : swingDirection === 'inward' - ? 1 - : 0 - - const hx = cx - nx * (width / 2) * (hingesSide === 'left' ? 1 : -1) - const hy = cy - ny * (width / 2) * (hingesSide === 'left' ? 1 : -1) - const swingSign = swingDirection === 'inward' ? 1 : -1 - const ox2 = cx + nx * (width / 2) * (hingesSide === 'left' ? 1 : -1) - const oy2 = cy + ny * (width / 2) * (hingesSide === 'left' ? 1 : -1) - const arcStrokeWidth = isSelected || isSelectionHighlighted ? '2' : '1.25' - const depthDirectionSign = - Math.sign((svgP4.x - svgP1.x) * px + (svgP4.y - svgP1.y) * py) || 1 - const depthExtraOffset = 0.005 - const doorCubeSize = Math.min(Math.max(width * 0.08, 0.06), 0.12) - const doorCubeInset = doorCubeSize * 0.5 - const doorAccent = - isSelected || isSelectionHighlighted ? '#f97316' : 'rgba(100, 116, 139, 0.82)' - const doorStroke = isDeleteHovered ? palette.deleteStroke : doorAccent - const doorSoftStroke = - isSelected || isSelectionHighlighted - ? 'rgba(251, 146, 60, 0.62)' - : 'rgba(148, 163, 184, 0.58)' - const doorLeafFill = - isSelected || isSelectionHighlighted ? 'rgba(255, 247, 237, 0.98)' : '#ffffff' - const doorOpeningFill = - isSelected || isSelectionHighlighted ? 'rgba(255, 247, 237, 0.98)' : '#ffffff' - const doorSwingFill = - isSelected || isSelectionHighlighted - ? 'rgba(251, 146, 60, 0.08)' - : 'rgba(148, 163, 184, 0.08)' - const doorCubeStroke = doorStroke - const hingeTangentSign = hingesSide === 'left' ? 1 : -1 - const hingeCubeCenter = { - x: hx + nx * hingeTangentSign * doorCubeInset, - y: hy + ny * hingeTangentSign * doorCubeInset, - } - const strikeCubeCenter = { - x: ox2 - nx * hingeTangentSign * doorCubeInset, - y: oy2 - ny * hingeTangentSign * doorCubeInset, - } - const leafHalfThickness = doorCubeSize * 0.18 - const leafSideOffset = hingeTangentSign * (doorCubeSize / 2 + leafHalfThickness) - const leafStart = { - x: hingeCubeCenter.x + px * swingSign * (doorCubeSize / 2) + nx * leafSideOffset, - y: hingeCubeCenter.y + py * swingSign * (doorCubeSize / 2) + ny * leafSideOffset, - } - const arcEnd = { - x: - strikeCubeCenter.x + - px * swingSign * (doorCubeSize / 2) - - nx * hingeTangentSign * (doorCubeSize / 2), - y: - strikeCubeCenter.y + - py * swingSign * (doorCubeSize / 2) - - ny * hingeTangentSign * (doorCubeSize / 2), - } - const swingRadius = Math.hypot(arcEnd.x - leafStart.x, arcEnd.y - leafStart.y) - const closedLeafVector = { - x: arcEnd.x - leafStart.x, - y: arcEnd.y - leafStart.y, - } - const openAngle = swingAngle * swingSign * hingeTangentSign - const openCos = Math.cos(openAngle) - const openSin = Math.sin(openAngle) - const leafEnd = { - x: leafStart.x + closedLeafVector.x * openCos - closedLeafVector.y * openSin, - y: leafStart.y + closedLeafVector.x * openSin + closedLeafVector.y * openCos, - } - const doorBackgroundPointList = [ - { - x: svgP1.x - px * depthDirectionSign * depthExtraOffset, - y: svgP1.y - py * depthDirectionSign * depthExtraOffset, - }, - { - x: svgP2.x - px * depthDirectionSign * depthExtraOffset, - y: svgP2.y - py * depthDirectionSign * depthExtraOffset, - }, - { - x: svgP3.x + px * depthDirectionSign * depthExtraOffset, - y: svgP3.y + py * depthDirectionSign * depthExtraOffset, - }, - { - x: svgP4.x + px * depthDirectionSign * depthExtraOffset, - y: svgP4.y + py * depthDirectionSign * depthExtraOffset, - }, - ] - const doorBackgroundPoints = doorBackgroundPointList - .map((point) => `${point.x},${point.y}`) - .join(' ') - const openingPlanPath = - opening.openingKind === 'opening' && opening.openingShape === 'rounded' - ? (() => { - const [a, b, c, d] = doorBackgroundPointList - if (!(a && b && c && d)) return null - - const tangentRadius = Math.min(width * 0.14, doorCubeSize * 1.6) - const depthRadius = Math.min( - Math.hypot(svgP4.x - svgP1.x, svgP4.y - svgP1.y) * 0.42, - doorCubeSize, - ) - const radius = Math.min(tangentRadius, depthRadius) - const offset = (from: Point2D, to: Point2D, distance: number) => { - const dx = to.x - from.x - const dy = to.y - from.y - const length = Math.hypot(dx, dy) - if (length < 1e-6) return from - return { - x: from.x + (dx / length) * Math.min(distance, length / 2), - y: from.y + (dy / length) * Math.min(distance, length / 2), - } - } - - const aToB = offset(a, b, radius) - const bToA = offset(b, a, radius) - const bToC = offset(b, c, radius) - const cToB = offset(c, b, radius) - const cToD = offset(c, d, radius) - const dToC = offset(d, c, radius) - const dToA = offset(d, a, radius) - const aToD = offset(a, d, radius) - - return [ - `M ${aToB.x} ${aToB.y}`, - `L ${bToA.x} ${bToA.y}`, - `Q ${b.x} ${b.y} ${bToC.x} ${bToC.y}`, - `L ${cToB.x} ${cToB.y}`, - `Q ${c.x} ${c.y} ${cToD.x} ${cToD.y}`, - `L ${dToC.x} ${dToC.y}`, - `Q ${d.x} ${d.y} ${dToA.x} ${dToA.y}`, - `L ${aToD.x} ${aToD.y}`, - `Q ${a.x} ${a.y} ${aToB.x} ${aToB.y}`, - 'Z', - ].join(' ') - })() - : null - const archPlanPath = - opening.openingKind === 'opening' && opening.openingShape === 'arch' - ? (() => { - const centerStart = { - x: (svgP1.x + svgP4.x) / 2, - y: (svgP1.y + svgP4.y) / 2, - } - const centerEnd = { - x: (svgP2.x + svgP3.x) / 2, - y: (svgP2.y + svgP3.y) / 2, - } - const midpoint = { - x: (centerStart.x + centerEnd.x) / 2, - y: (centerStart.y + centerEnd.y) / 2, - } - const bow = Math.min(width * 0.18, doorCubeSize * 1.8) - return `M ${centerStart.x} ${centerStart.y} Q ${midpoint.x + px * bow} ${ - midpoint.y + py * bow - } ${centerEnd.x} ${centerEnd.y}` - })() - : null - const leafPolygonPoints = [ - { - x: leafStart.x - nx * leafHalfThickness, - y: leafStart.y - ny * leafHalfThickness, - }, - { - x: leafEnd.x - nx * leafHalfThickness, - y: leafEnd.y - ny * leafHalfThickness, - }, - { - x: leafEnd.x + nx * leafHalfThickness, - y: leafEnd.y + ny * leafHalfThickness, - }, - { - x: leafStart.x + nx * leafHalfThickness, - y: leafStart.y + ny * leafHalfThickness, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' ') - const swingSweepPath = - swingRadius > 1e-6 - ? `M ${leafStart.x} ${leafStart.y} L ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y} Z` - : null - const jambTickSize = doorCubeSize * 0.82 - const hingeMarkerRadius = Math.min(Math.max(doorCubeSize * 0.22, 0.018), 0.034) - const strikeTickStart = { - x: strikeCubeCenter.x - px * swingSign * jambTickSize * 0.5, - y: strikeCubeCenter.y - py * swingSign * jambTickSize * 0.5, - } - const strikeTickEnd = { - x: strikeCubeCenter.x + px * swingSign * jambTickSize * 0.5, - y: strikeCubeCenter.y + py * swingSign * jambTickSize * 0.5, - } - const closedLeafHintPoints = [ - { - x: leafStart.x - nx * leafHalfThickness * 0.7, - y: leafStart.y - ny * leafHalfThickness * 0.7, - }, - { - x: arcEnd.x - nx * leafHalfThickness * 0.7, - y: arcEnd.y - ny * leafHalfThickness * 0.7, - }, - { - x: arcEnd.x + nx * leafHalfThickness * 0.7, - y: arcEnd.y + ny * leafHalfThickness * 0.7, - }, - { - x: leafStart.x + nx * leafHalfThickness * 0.7, - y: leafStart.y + ny * leafHalfThickness * 0.7, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' ') - const openingCenterLineStart = { - x: (svgP1.x + svgP4.x) / 2, - y: (svgP1.y + svgP4.y) / 2, - } - const openingCenterLineEnd = { - x: (svgP2.x + svgP3.x) / 2, - y: (svgP2.y + svgP3.y) / 2, - } - const isFoldingDoor = opening.doorType === 'folding' - const foldingPanelCount = opening.leafCount === 2 ? 2 : 4 - const foldingAmount = Math.max(0, Math.min(1, opening.operationState ?? 0)) - const foldingSpan = Math.max(1e-6, Math.hypot(svgP2.x - svgP1.x, svgP2.y - svgP1.y)) - const foldingPanelLength = foldingSpan / foldingPanelCount - const foldingAngle = Math.PI * 0.44 * foldingAmount - const foldingPoints = isFoldingDoor - ? Array.from({ length: foldingPanelCount + 1 }).reduce( - (points, _, index) => { - if (index === 0) return [{ x: svgP1.x, y: svgP1.y }] - - const previous = points[index - 1]! - const direction = (index - 1) % 2 === 0 ? -1 : 1 - const angle = direction * foldingAngle - const along = Math.cos(angle) * foldingPanelLength - const out = Math.sin(angle) * foldingPanelLength * swingSign - points.push({ - x: previous.x + nx * along + px * out, - y: previous.y + ny * along + py * out, - }) - return points - }, - [], - ) - : [] - const foldingPath = - foldingPoints.length > 0 - ? foldingPoints - .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) - .join(' ') - : null - const isPocketDoor = opening.doorType === 'pocket' - const pocketAmount = Math.max(0, Math.min(1, opening.operationState ?? 0)) - const pocketSign = opening.slideDirection === 'right' ? 1 : -1 - const pocketShift = pocketSign * foldingSpan * pocketAmount - const pocketTrackStart = - pocketSign > 0 - ? svgP1 - : { x: svgP1.x - nx * foldingSpan, y: svgP1.y - ny * foldingSpan } - const pocketTrackEnd = - pocketSign > 0 - ? { x: svgP2.x + nx * foldingSpan, y: svgP2.y + ny * foldingSpan } - : svgP2 - const pocketLeafStart = { - x: svgP1.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5, - y: svgP1.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5, - } - const pocketLeafEnd = { - x: svgP2.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5, - y: svgP2.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5, - } - const pocketLeafPoints = [ - { - x: pocketLeafStart.x - px * leafHalfThickness, - y: pocketLeafStart.y - py * leafHalfThickness, - }, - { - x: pocketLeafEnd.x - px * leafHalfThickness, - y: pocketLeafEnd.y - py * leafHalfThickness, - }, - { - x: pocketLeafEnd.x + px * leafHalfThickness, - y: pocketLeafEnd.y + py * leafHalfThickness, - }, - { - x: pocketLeafStart.x + px * leafHalfThickness, - y: pocketLeafStart.y + py * leafHalfThickness, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' ') - const isBarnDoor = opening.doorType === 'barn' - const barnLeafStart = { - x: pocketLeafStart.x + px * swingSign * doorCubeSize * 0.75, - y: pocketLeafStart.y + py * swingSign * doorCubeSize * 0.75, - } - const barnLeafEnd = { - x: pocketLeafEnd.x + px * swingSign * doorCubeSize * 0.75, - y: pocketLeafEnd.y + py * swingSign * doorCubeSize * 0.75, - } - const barnLeafPoints = [ - { - x: barnLeafStart.x - px * leafHalfThickness, - y: barnLeafStart.y - py * leafHalfThickness, - }, - { - x: barnLeafEnd.x - px * leafHalfThickness, - y: barnLeafEnd.y - py * leafHalfThickness, - }, - { - x: barnLeafEnd.x + px * leafHalfThickness, - y: barnLeafEnd.y + py * leafHalfThickness, - }, - { - x: barnLeafStart.x + px * leafHalfThickness, - y: barnLeafStart.y + py * leafHalfThickness, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' ') - const isSlidingDoor = opening.doorType === 'sliding' - const slidingPanelSpan = foldingSpan * 0.54 - const slidingActiveOnRight = opening.slideDirection !== 'right' - const slidingFixedSign = slidingActiveOnRight ? -1 : 1 - const slidingActiveSign = slidingActiveOnRight ? 1 : -1 - const slidingFixedCenter = slidingFixedSign * foldingSpan * 0.23 - const slidingActiveCenter = - slidingActiveSign * foldingSpan * 0.23 - - slidingActiveSign * foldingSpan * 0.44 * pocketAmount - const slidingPanelPoints = (centerOffset: number, faceOffset: number) => { - const start = { - x: - svgP1.x + - nx * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) + - px * swingSign * faceOffset, - y: - svgP1.y + - ny * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) + - py * swingSign * faceOffset, - } - const end = { - x: - svgP1.x + - nx * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) + - px * swingSign * faceOffset, - y: - svgP1.y + - ny * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) + - py * swingSign * faceOffset, - } - return [ - { x: start.x - px * leafHalfThickness, y: start.y - py * leafHalfThickness }, - { x: end.x - px * leafHalfThickness, y: end.y - py * leafHalfThickness }, - { x: end.x + px * leafHalfThickness, y: end.y + py * leafHalfThickness }, - { x: start.x + px * leafHalfThickness, y: start.y + py * leafHalfThickness }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' ') - } - const slidingFixedPoints = slidingPanelPoints(slidingFixedCenter, doorCubeSize * 0.34) - const slidingActivePoints = slidingPanelPoints(slidingActiveCenter, doorCubeSize * 0.68) - const isGarageSectionalDoor = opening.doorType === 'garage-sectional' - const isGarageRollupDoor = opening.doorType === 'garage-rollup' - const isGarageTiltupDoor = opening.doorType === 'garage-tiltup' - const garagePanelCount = Math.max(3, Math.min(12, opening.garagePanelCount ?? 4)) - const garagePanelLines = Array.from({ length: garagePanelCount - 1 }, (_, index) => { - const t = (index + 1) / garagePanelCount - return { - start: { - x: svgP1.x + (svgP2.x - svgP1.x) * t, - y: svgP1.y + (svgP2.y - svgP1.y) * t, - }, - end: { - x: svgP1.x + (svgP2.x - svgP1.x) * t + px * swingSign * doorCubeSize * 0.78, - y: svgP1.y + (svgP2.y - svgP1.y) * t + py * swingSign * doorCubeSize * 0.78, - }, - } - }) - const isDoubleSwingDoor = opening.doorType === 'double' || opening.doorType === 'french' - const doubleLeafPlans = isDoubleSwingDoor - ? ( - [ - { - key: 'left', - hingePoint: { x: cx - nx * (width / 2), y: cy - ny * (width / 2) }, - strikePoint: { x: cx, y: cy }, - }, - { - key: 'right', - hingePoint: { x: cx + nx * (width / 2), y: cy + ny * (width / 2) }, - strikePoint: { x: cx, y: cy }, - }, - ] as const - ).map(({ key, hingePoint, strikePoint }) => { - const tangentSign = key === 'left' ? 1 : -1 - const planHingeCubeCenter = { - x: hingePoint.x + nx * tangentSign * doorCubeInset, - y: hingePoint.y + ny * tangentSign * doorCubeInset, - } - const planStrikeCubeCenter = { - x: strikePoint.x - nx * tangentSign * doorCubeInset, - y: strikePoint.y - ny * tangentSign * doorCubeInset, - } - const planLeafStart = { - x: - planHingeCubeCenter.x + - px * swingSign * (doorCubeSize / 2) + - nx * tangentSign * (doorCubeSize / 2 + leafHalfThickness), - y: - planHingeCubeCenter.y + - py * swingSign * (doorCubeSize / 2) + - ny * tangentSign * (doorCubeSize / 2 + leafHalfThickness), - } - const planArcEnd = { - x: - planStrikeCubeCenter.x + - px * swingSign * (doorCubeSize / 2) - - nx * tangentSign * (doorCubeSize / 2), - y: - planStrikeCubeCenter.y + - py * swingSign * (doorCubeSize / 2) - - ny * tangentSign * (doorCubeSize / 2), - } - const planSwingRadius = Math.hypot( - planArcEnd.x - planLeafStart.x, - planArcEnd.y - planLeafStart.y, - ) - const planClosedLeafVector = { - x: planArcEnd.x - planLeafStart.x, - y: planArcEnd.y - planLeafStart.y, - } - const planOpenAngle = swingAngle * swingSign * tangentSign - const planOpenCos = Math.cos(planOpenAngle) - const planOpenSin = Math.sin(planOpenAngle) - const planLeafEnd = { - x: - planLeafStart.x + - planClosedLeafVector.x * planOpenCos - - planClosedLeafVector.y * planOpenSin, - y: - planLeafStart.y + - planClosedLeafVector.x * planOpenSin + - planClosedLeafVector.y * planOpenCos, - } - const planSweepFlag = - key === 'left' - ? swingDirection === 'inward' - ? 0 - : 1 - : swingDirection === 'inward' - ? 1 - : 0 - - return { - key, - hingeCubeCenter: planHingeCubeCenter, - strikeCubeCenter: planStrikeCubeCenter, - hingeMarkerX: planHingeCubeCenter.x, - hingeMarkerY: planHingeCubeCenter.y, - swingRadius: planSwingRadius, - sweepFlag: planSweepFlag, - arcEnd: planArcEnd, - leafEnd: planLeafEnd, - leafPolygonPoints: [ - { - x: planLeafStart.x - nx * leafHalfThickness, - y: planLeafStart.y - ny * leafHalfThickness, - }, - { - x: planLeafEnd.x - nx * leafHalfThickness, - y: planLeafEnd.y - ny * leafHalfThickness, - }, - { - x: planLeafEnd.x + nx * leafHalfThickness, - y: planLeafEnd.y + ny * leafHalfThickness, - }, - { - x: planLeafStart.x + nx * leafHalfThickness, - y: planLeafStart.y + ny * leafHalfThickness, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' '), - closedLeafHintPoints: [ - { - x: planLeafStart.x - nx * leafHalfThickness * 0.7, - y: planLeafStart.y - ny * leafHalfThickness * 0.7, - }, - { - x: planArcEnd.x - nx * leafHalfThickness * 0.7, - y: planArcEnd.y - ny * leafHalfThickness * 0.7, - }, - { - x: planArcEnd.x + nx * leafHalfThickness * 0.7, - y: planArcEnd.y + ny * leafHalfThickness * 0.7, - }, - { - x: planLeafStart.x + nx * leafHalfThickness * 0.7, - y: planLeafStart.y + ny * leafHalfThickness * 0.7, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' '), - } - }) - : [] - - return ( - { - event.stopPropagation() - onOpeningSelect(opening.id, event) - } - : undefined - } - onDoubleClick={ - canFocusGeometry - ? (event) => { - event.stopPropagation() - onOpeningDoubleClick(opening) - } - : undefined - } - onPointerDown={ - canFocusGeometry && isSelected - ? (event) => { - if (event.button === 0) { - onOpeningPointerDown(opening.id, event) - } - } - : undefined - } - onPointerEnter={ - canSelectGeometry - ? () => { - onWallHoverChange(null) - onOpeningHoverChange(opening.id) - } - : undefined - } - onPointerLeave={canSelectGeometry ? () => onOpeningHoverChange(null) : undefined} - style={{ cursor: EDITOR_CURSOR }} - > - {canSelectGeometry && ( - - )} - {opening.openingKind === 'opening' ? ( - <> - {openingPlanPath ? ( - - ) : ( - - )} - - {archPlanPath && ( - - )} - - ) : ( - <> - - {isFoldingDoor ? ( - <> - - {foldingPath && ( - - )} - {foldingPoints.map((point, index) => ( - - ))} - - ) : isPocketDoor ? ( - <> - - - - - - ) : isBarnDoor ? ( - <> - - - - {[0.28, 0.72].map((ratio) => { - const wheel = { - x: barnLeafStart.x + (barnLeafEnd.x - barnLeafStart.x) * ratio, - y: barnLeafStart.y + (barnLeafEnd.y - barnLeafStart.y) * ratio, - } - return ( - - ) - })} - - ) : isSlidingDoor ? ( - <> - - - - - - ) : isGarageSectionalDoor || isGarageRollupDoor || isGarageTiltupDoor ? ( - <> - - - {isGarageRollupDoor ? ( - - ) : isGarageTiltupDoor ? ( - - ) : ( - garagePanelLines.map((line, index) => ( - - )) - )} - - ) : isDoubleSwingDoor ? ( - <> - {doubleLeafPlans.map((leaf) => - leaf.swingRadius > 1e-6 ? ( - - ) : null, - )} - {swingAngle > 0.03 && - doubleLeafPlans.map((leaf) => ( - - ))} - {doubleLeafPlans.map((leaf) => ( - - ))} - {doubleLeafPlans.map((leaf) => ( - - ))} - {doubleLeafPlans.map((leaf) => ( - - ))} - - ) : ( - <> - {swingSweepPath && ( - - )} - {swingAngle > 0.03 && ( - - )} - {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( - - ))} - - - - - - )} - - )} - {isSelected ? ( - <> - - - - - ) : null} - - ) - } - - return null - })} - - - - ) -}) - -const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ - canFocusGeometry, - canSelectGeometry, - fenceEntries, - highlightedIdSet, - hoveredFenceId, - isDeleteMode, - onFenceDoubleClick, - onFenceHoverChange, - onFenceHoverEnter, - onFencePointerDown, - onFenceSelect, - palette, - selectedIdSet, -}: { - canFocusGeometry: boolean - canSelectGeometry: boolean - fenceEntries: FloorplanFenceEntry[] - highlightedIdSet: ReadonlySet - hoveredFenceId: FenceNode['id'] | null - isDeleteMode: boolean - onFenceDoubleClick: (fence: FenceNode, event: ReactMouseEvent) => void - onFenceHoverChange: (fenceId: FenceNode['id'] | null) => void - onFenceHoverEnter: (fenceId: FenceNode['id']) => void - onFencePointerDown: (fenceId: FenceNode['id'], event: ReactPointerEvent) => void - onFenceSelect: (fence: FenceNode, event: ReactMouseEvent) => void - palette: FloorplanPalette - selectedIdSet: ReadonlySet -}) { - if (fenceEntries.length === 0) { - return null - } - - return ( - <> - {fenceEntries.map(({ fence, markerFrames, path }) => { - const isSelected = selectedIdSet.has(fence.id) - const isHighlighted = highlightedIdSet.has(fence.id) - const isHovered = hoveredFenceId === fence.id - const isDeleteHovered = isDeleteMode && isHovered - const isActive = isSelected || isHighlighted - const showInteractiveChrome = isActive || isHovered - const fenceStroke = isDeleteHovered - ? palette.deleteStroke - : isActive - ? palette.selectedStroke - : isHovered - ? palette.wallHoverStroke - : '#111827' - const fenceAccent = fenceStroke - const fenceUnderlayStroke = isDeleteHovered ? palette.surface : 'rgba(255, 255, 255, 0.98)' - const fenceGlowStroke = isDeleteHovered - ? palette.deleteStroke - : isActive - ? palette.selectedStroke - : palette.wallHoverStroke - const fenceGlowOpacity = isDeleteHovered ? 0.18 : isActive ? 0.22 : isHovered ? 0.14 : 0 - const fenceUnderlayWidth = isActive ? '6.5' : isHovered ? '6' : '5.2' - const fenceStrokeWidth = isActive ? '2.6' : isHovered ? '2.35' : '2.05' - const showFenceInfill = fence.showInfill ?? true - const visibleMarkerFrames = showFenceInfill - ? markerFrames - : markerFrames.filter( - (_, markerIndex) => markerIndex === 0 || markerIndex === markerFrames.length - 1, - ) - const privacyMarkerWidth = clamp(fence.postSize * 0.58, 0.038, 0.068) - const privacyMarkerHeight = clamp( - Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4), - 0.1, - 0.17, - ) - const railMarkerRadius = clamp(fence.postSize * 0.52, 0.048, 0.078) - const slatMarkerHalf = clamp(fence.postSize * 0.42, 0.03, 0.055) - const markerStrokeWidth = isActive ? '1.65' : '1.35' - - return ( - onFenceHoverEnter(fence.id) : undefined} - onPointerLeave={canSelectGeometry ? () => onFenceHoverChange(null) : undefined} - > - {showInteractiveChrome ? ( - - ) : null} - - - {visibleMarkerFrames.map(({ angleDeg, point }, markerIndex) => { - const svgPoint = toSvgPoint(point) - - if (fence.style === 'privacy') { - return ( - - - - - ) - } - - if (fence.style === 'rail') { - return ( - - - - - - ) - } - - return ( - - - - - - - ) - })} - { - event.stopPropagation() - onFenceSelect(fence, event) - } - : undefined - } - onDoubleClick={ - canFocusGeometry - ? (event) => { - event.stopPropagation() - onFenceDoubleClick(fence, event) - } - : undefined - } - onPointerDown={ - canSelectGeometry && isSelected - ? (event) => { - if (event.button === 0) { - onFencePointerDown(fence.id, event) - } - } - : undefined - } - pointerEvents={canSelectGeometry ? 'stroke' : 'none'} - stroke="transparent" - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH} - style={canSelectGeometry ? { cursor: EDITOR_CURSOR } : undefined} - vectorEffect="non-scaling-stroke" - /> - - ) - })} - - ) -}) - -const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ - canSelectElevators, - elevatorEntries, - highlightedIdSet, - hoveredElevatorId, - isDeleteMode, - onElevatorHoverChange, - onElevatorHoverEnter, - onElevatorPointerDown, - onElevatorResizePointerDown, - onElevatorResizePointerMove, - onElevatorResizePointerUp, - onElevatorSelect, - palette, - selectedIdSet, - wallSelectionHatchId, -}: { - canSelectElevators: boolean - elevatorEntries: FloorplanElevatorEntry[] - highlightedIdSet: ReadonlySet - hoveredElevatorId: ElevatorNode['id'] | null - isDeleteMode: boolean - onElevatorHoverChange: (elevatorId: ElevatorNode['id'] | null) => void - onElevatorHoverEnter: (elevatorId: ElevatorNode['id']) => void - onElevatorPointerDown: ( - elevatorId: ElevatorNode['id'], - event: ReactPointerEvent, - ) => void - onElevatorResizePointerDown: ( - entry: FloorplanElevatorEntry, - handle: ElevatorResizeHandle, - event: ReactPointerEvent, - ) => void - onElevatorResizePointerMove: (event: ReactPointerEvent) => void - onElevatorResizePointerUp: (event: ReactPointerEvent) => void - onElevatorSelect: (elevator: ElevatorNode, event: ReactMouseEvent) => void - palette: FloorplanPalette - selectedIdSet: ReadonlySet - wallSelectionHatchId: string -}) { - if (elevatorEntries.length === 0) { - return null - } - - return ( - - {elevatorEntries.map((entry) => { - const { elevator } = entry - const isSelected = selectedIdSet.has(elevator.id) - const isHighlighted = highlightedIdSet.has(elevator.id) - const isHovered = hoveredElevatorId === elevator.id - const isDeleteHovered = isDeleteMode && isHovered - const isActive = isSelected || isHighlighted - const showChrome = isActive || isHovered - const isGlassShaft = elevator.shaftStyle === 'glass' - const shaftShellFill = isDeleteHovered - ? palette.deleteFill - : isActive - ? `url(#${wallSelectionHatchId})` - : isGlassShaft - ? '#dff6ff' - : '#e5e7eb' - const shaftClearFill = isGlassShaft ? '#ecfeff' : '#f8fafc' - const shaftShellOpacity = isDeleteHovered ? 0.38 : isActive ? 0.9 : isHovered ? 0.86 : 0.76 - const stroke = isDeleteHovered - ? palette.deleteStroke - : isActive - ? palette.selectedStroke - : isHovered - ? palette.wallHoverStroke - : isGlassShaft - ? '#0891b2' - : '#475569' - const doorStroke = isDeleteHovered - ? palette.deleteStroke - : isActive - ? palette.selectedStroke - : '#0369a1' - const centerX = toSvgX(entry.center.x) - const centerY = toSvgY(entry.center.y) - const rotationDeg = (-entry.rotation * 180) / Math.PI - const shaftWidth = entry.outerHalfWidth * 2 - const shaftDepth = entry.outerHalfDepth * 2 - const shaftClearX = -entry.shaftWidth / 2 - const shaftClearY = -entry.shaftDepth / 2 - const cabX = -entry.cabWidth / 2 - const cabY = entry.cabCenterLocalY - entry.cabDepth / 2 - const frontLocalY = -entry.outerHalfDepth - const doorHalfWidth = entry.doorWidth / 2 - const doorTrackY = frontLocalY - 0.075 - const callStationX = Math.min(entry.outerHalfWidth - 0.12, doorHalfWidth + 0.2) - const callStationY = frontLocalY - 0.16 - const cabFill = entry.isCarOnLevel - ? '#dcfce7' - : entry.isTargetLevel || entry.isQueuedLevel - ? '#e0f2fe' - : '#f8fafc' - const cabStroke = entry.isCarOnLevel - ? '#16a34a' - : entry.isTargetLevel || entry.isQueuedLevel - ? '#0ea5e9' - : '#64748b' - const showCarMarker = entry.isCarOnLevel || entry.isTargetLevel || entry.isQueuedLevel - const carFill = entry.isCarOnLevel ? '#22c55e' : '#ffffff' - const carStroke = entry.isCarOnLevel ? '#15803d' : '#0ea5e9' - const resizeHandles = [ - { - cursor: 'ew-resize', - handle: 'width-negative' as const, - localX: -entry.outerHalfWidth, - localY: 0, - }, - { - cursor: 'ew-resize', - handle: 'width-positive' as const, - localX: entry.outerHalfWidth, - localY: 0, - }, - { - cursor: 'ns-resize', - handle: 'depth-negative' as const, - localX: 0, - localY: -entry.outerHalfDepth, - }, - { - cursor: 'ns-resize', - handle: 'depth-positive' as const, - localX: 0, - localY: entry.outerHalfDepth, - }, - ].map((handle) => { - const [offsetX, offsetY] = rotatePlanVector(handle.localX, handle.localY, entry.rotation) - return { - ...handle, - x: entry.center.x + offsetX, - y: entry.center.y + offsetY, - } - }) - const rangeStep = 0.18 - const rangeHeight = Math.max(0, (entry.servedLevels.length - 1) * rangeStep) - const [rangeOffsetX, rangeOffsetY] = rotatePlanVector( - entry.outerHalfWidth + 0.38, - 0, - entry.rotation, - ) - const rangeX = entry.center.x + rangeOffsetX - const rangeTopY = entry.center.y + rangeOffsetY - rangeHeight / 2 - const rangeBottomY = entry.center.y + rangeOffsetY + rangeHeight / 2 - - return ( - onElevatorHoverEnter(elevator.id) : undefined - } - onPointerLeave={canSelectElevators ? () => onElevatorHoverChange(null) : undefined} - > - {showChrome ? ( - - ) : null} - - - - - - - - - {entry.doorStyle === 'center-opening' ? ( - <> - - - - ) : ( - - )} - - - {showCarMarker ? ( - - ) : null} - - { - event.stopPropagation() - onElevatorSelect(elevator, event) - } - : undefined - } - onPointerDown={ - canSelectElevators && isSelected - ? (event) => { - if (event.button === 0) { - onElevatorPointerDown(elevator.id, event) - } - } - : undefined - } - points={entry.points} - pointerEvents={canSelectElevators ? 'all' : 'none'} - style={canSelectElevators ? { cursor: EDITOR_CURSOR } : undefined} - > - {elevator.name || 'Elevator'} - - {isSelected && entry.servedLevels.length > 1 ? ( - - - {entry.servedLevels.map((level, index) => { - const y = rangeBottomY - index * rangeStep - const isUnavailable = level.isDisabled || level.isServiceOnly - const markerFill = level.isCurrent - ? '#22c55e' - : level.isTarget || level.isQueued - ? '#38bdf8' - : isUnavailable - ? '#94a3b8' - : '#ffffff' - const markerStroke = isUnavailable ? '#64748b' : '#0369a1' - - return ( - - - - {index + 1} - - - ) - })} - - ) : null} - {isSelected && canSelectElevators && !isDeleteMode - ? resizeHandles.map((handle) => ( - - onElevatorResizePointerDown(entry, handle.handle, event) - } - onPointerMove={onElevatorResizePointerMove} - onPointerUp={onElevatorResizePointerUp} - r={0.075} - stroke="#0284c7" - strokeWidth="1.7" - style={{ cursor: handle.cursor }} - vectorEffect="non-scaling-stroke" - /> - )) - : null} - - ) - })} - - ) -}) - -// Renders an item's 2D floor-plan image (top-down view, object-fit:contain) -// inside its footprint rectangle. Placed at the same scene position/rotation -// as the polygon so it lines up exactly. - -function FloorplanItemImage({ - url, - center, - rotation, - width, - depth, -}: { - url: string - center: Point2D - rotation: number - width: number - depth: number -}) { - const resolvedUrl = useResolvedAssetUrl(url) - if (!resolvedUrl) return null - // The PNG is captured with the modal's top-down camera (default up = +Y), - // so its pixel-right is world +X and pixel-up is world -Z. The plan SVG - // negates both axes (`toSvgX(v) = -v`, `toSvgY(v) = -v`), which together - // are a 180° rotation — so the captured image lands upside-down / - // mirrored when overlaid as-is. Bake that 180° into the image transform - // here; the panel / modal previews use the PNG directly and stay correct. - const rotationDeg = (-rotation * 180) / Math.PI + 180 - return ( - - - - ) -} - -const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ - canFocusItems, - canFocusSpawns, - canFocusStairs, - canSelectItems, - canSelectSpawns, - canSelectStairs, - highlightedIdSet, - hoveredItemId, - hoveredSpawnId, - hoveredStairId, - isDeleteMode, - isFurnishContextActive, - itemEntries, - onItemDoubleClick, - onItemHoverChange, - onItemHoverEnter, - onItemPointerDown, - onItemSelect, - onSpawnDoubleClick, - onSpawnHoverChange, - onSpawnHoverEnter, - onSpawnPointerDown, - onSpawnSelect, - onStairDoubleClick, - onStairHoverChange, - onStairHoverEnter, - onStairPointerDown, - onStairSelect, - palette, - selectedIdSet, - spawnEntries, - stairEntries, - unit, - wallSelectionHatchId, -}: { - canFocusItems: boolean - canFocusSpawns: boolean - canFocusStairs: boolean - canSelectItems: boolean - canSelectSpawns: boolean - canSelectStairs: boolean - highlightedIdSet: ReadonlySet - hoveredItemId: ItemNode['id'] | null - hoveredSpawnId: SpawnNode['id'] | null - hoveredStairId: StairNode['id'] | null - isDeleteMode: boolean - isFurnishContextActive: boolean - itemEntries: FloorplanItemEntry[] - onItemDoubleClick: (item: ItemNode, event: ReactMouseEvent) => void - onItemHoverChange: (itemId: ItemNode['id'] | null) => void - onItemHoverEnter: (itemId: ItemNode['id']) => void - onItemPointerDown: (itemId: ItemNode['id'], event: ReactPointerEvent) => void - onItemSelect: (itemId: ItemNode['id'], event: ReactMouseEvent) => void - onSpawnDoubleClick: (spawn: SpawnNode, event: ReactMouseEvent) => void - onSpawnHoverChange: (spawnId: SpawnNode['id'] | null) => void - onSpawnHoverEnter: (spawnId: SpawnNode['id']) => void - onSpawnPointerDown: (spawnId: SpawnNode['id'], event: ReactPointerEvent) => void - onSpawnSelect: (spawnId: SpawnNode['id'], event: ReactMouseEvent) => void - onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent) => void - onStairHoverChange: (stairId: StairNode['id'] | null) => void - onStairHoverEnter: (stairId: StairNode['id']) => void - onStairPointerDown: (stairId: StairNode['id'], event: ReactPointerEvent) => void - onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent) => void - palette: FloorplanPalette - selectedIdSet: ReadonlySet - spawnEntries: FloorplanSpawnEntry[] - stairEntries: FloorplanStairEntry[] - unit: 'metric' | 'imperial' - wallSelectionHatchId: string -}) { - if (itemEntries.length === 0 && stairEntries.length === 0 && spawnEntries.length === 0) { - return null - } - - const itemNodes = itemEntries.map((itemEntry) => { - const { item, points, polygon, center, rotation, width, depth } = itemEntry - const itemDimensionMeasurements = getItemDimensionMeasurementOverlays(itemEntry, unit) - const isSelected = selectedIdSet.has(item.id) - const isHighlighted = highlightedIdSet.has(item.id) - const isHovered = hoveredItemId === item.id - const isDeleteHovered = isDeleteMode && isHovered - const isSelectionActive = isSelected || isHighlighted - const showHighlight = isDeleteHovered || (isHovered && !isSelectionActive) - const stroke = isDeleteHovered - ? palette.deleteStroke - : isSelectionActive - ? palette.selectedStroke - : palette.wallStroke - const highlightStroke = isDeleteHovered - ? palette.deleteStroke - : isSelectionActive - ? palette.selectedStroke - : palette.wallHoverStroke - const fill = isDeleteHovered ? palette.deleteFill : palette.openingFill - const crossStrokeOpacity = isDeleteHovered - ? 0.76 - : isSelectionActive - ? 0.72 - : isHovered - ? 0.58 - : 0.52 - const floorPlanUrl = item.asset.floorPlanUrl - const diagonalAStart = polygon[0] - const diagonalAEnd = polygon[2] - const diagonalBStart = polygon[1] - const diagonalBEnd = polygon[3] - - return ( - { - event.stopPropagation() - onItemSelect(item.id, event) - } - : undefined - } - onDoubleClick={ - canFocusItems - ? (event) => { - event.stopPropagation() - onItemDoubleClick(item, event) - } - : undefined - } - onPointerDown={ - canFocusItems && isSelected - ? (event) => { - if (event.button === 0) { - onItemPointerDown(item.id, event) - } - } - : undefined - } - onPointerEnter={canSelectItems ? () => onItemHoverEnter(item.id) : undefined} - onPointerLeave={canSelectItems ? () => onItemHoverChange(null) : undefined} - pointerEvents={canSelectItems ? undefined : 'none'} - style={canSelectItems ? { cursor: EDITOR_CURSOR } : undefined} - > - {item.name || item.asset.name} - - - - {floorPlanUrl ? ( - - ) : ( - <> - {diagonalAStart && diagonalAEnd && ( - - )} - {diagonalBStart && diagonalBEnd && ( - - )} - - )} - {isSelected && !isDeleteHovered ? ( - - ) : null} - {itemDimensionMeasurements.length > 0 ? ( - - ) : null} - - ) - }) - - const spawnNodes = spawnEntries.map(({ spawn, position, rotation }) => { - const isSelected = selectedIdSet.has(spawn.id) - const isHighlighted = highlightedIdSet.has(spawn.id) - const isHovered = hoveredSpawnId === spawn.id - const isDeleteHovered = isDeleteMode && isHovered - const isSelectionActive = isSelected || isHighlighted - const showHighlight = isDeleteHovered || (isHovered && !isSelectionActive) - const stroke = isDeleteHovered - ? palette.deleteStroke - : isSelectionActive - ? palette.selectedStroke - : '#16a34a' - const fill = isDeleteHovered ? palette.deleteFill : '#22c55e' - const rotationDeg = (-rotation * 180) / Math.PI - - return ( - { - event.stopPropagation() - onSpawnSelect(spawn.id, event) - } - : undefined - } - onDoubleClick={ - canFocusSpawns - ? (event) => { - event.stopPropagation() - onSpawnDoubleClick(spawn, event) - } - : undefined - } - onPointerDown={ - canFocusSpawns && isSelected - ? (event) => { - if (event.button === 0) { - onSpawnPointerDown(spawn.id, event) - } - } - : undefined - } - onPointerEnter={canSelectSpawns ? () => onSpawnHoverEnter(spawn.id) : undefined} - onPointerLeave={canSelectSpawns ? () => onSpawnHoverChange(null) : undefined} - pointerEvents={canSelectSpawns ? undefined : 'none'} - style={canSelectSpawns ? { cursor: EDITOR_CURSOR } : undefined} - transform={`translate(${toSvgX(position.x)} ${toSvgY(position.y)}) rotate(${rotationDeg})`} - > - {spawn.name || 'Spawn Point'} - - - - - - - - ) - }) - - return ( - <> - {isFurnishContextActive ? ( - <> - - {itemNodes} - {spawnNodes} - - ) : ( - <> - {itemNodes} - {spawnNodes} - - - )} - - ) -}) - const FloorplanSiteLayer = memo(function FloorplanSiteLayer({ isEditing, sitePolygon, @@ -7054,26 +3366,6 @@ const FloorplanZoneLayer = memo(function FloorplanZoneLayer({ const FLOORPLAN_ZONE_LABEL_FONT_SIZE = 0.2 -/** Compute polygon centroid using the shoelace formula */ -const polygonCentroid = (polygon: Point2D[]): { x: number; y: number } => { - let signedArea = 0 - let cx = 0 - let cy = 0 - - for (let i = 0; i < polygon.length; i++) { - const p0 = polygon[i]! - const p1 = polygon[(i + 1) % polygon.length]! - const cross = p0.x * p1.y - p1.x * p0.y - signedArea += cross - cx += (p0.x + p1.x) * cross - cy += (p0.y + p1.y) * cross - } - - signedArea /= 2 - const factor = 1 / (6 * signedArea) - return { x: cx * factor, y: cy * factor } -} - function FloorplanZoneLabelInput({ centroid, svgRef, @@ -7258,421 +3550,6 @@ function FloorplanZoneLabel({ ) } -const FloorplanZoneLabelLayer = memo(function FloorplanZoneLabelLayer({ - onLabelHoverChange, - onZoneLabelClick, - selectedZoneId, - svgRef, - viewBox, - zonePolygons, -}: { - onLabelHoverChange: (zoneId: ZoneNodeType['id'] | null) => void - onZoneLabelClick: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent) => void - selectedZoneId: ZoneNodeType['id'] | null - svgRef: React.RefObject - viewBox: { minX: number; minY: number; width: number; height: number } - zonePolygons: ZonePolygonEntry[] -}) { - const [editingZoneId, setEditingZoneId] = useState(null) - - // Listen for edit-label events (from 2D label click or external triggers) - useEffect(() => { - const handler = (event: { zoneId: string }) => { - setEditingZoneId(event.zoneId as ZoneNodeType['id']) - } - emitter.on('zone:edit-label' as any, handler as any) - return () => { - emitter.off('zone:edit-label' as any, handler as any) - } - }, []) - - // Clear editing when selection changes away - useEffect(() => { - if (editingZoneId && selectedZoneId !== editingZoneId) { - setEditingZoneId(null) - } - }, [selectedZoneId, editingZoneId]) - - return ( - <> - {zonePolygons.map(({ zone, polygon }) => { - if (polygon.length < 3) return null - const rawCentroid = polygonCentroid(polygon) - const centroid = toSvgPoint(rawCentroid) - const isEditing = editingZoneId === zone.id - - if (isEditing) { - return ( - setEditingZoneId(null)} - svgRef={svgRef} - viewBox={viewBox} - zone={zone} - /> - ) - } - - return ( - - ) - })} - - ) -}) - -const FloorplanWallEndpointLayer = memo(function FloorplanWallEndpointLayer({ - endpointHandles, - hoveredEndpointId, - onWallEndpointPointerDown, - onEndpointHoverChange, - palette, - unitsPerPixel, -}: { - endpointHandles: Array<{ - wall: WallNode - endpoint: WallEndpoint - point: WallPlanPoint - isSelected: boolean - isActive: boolean - }> - onWallEndpointPointerDown: ( - wall: WallNode, - endpoint: WallEndpoint, - event: ReactPointerEvent, - ) => void - hoveredEndpointId: string | null - onEndpointHoverChange: (endpointId: string | null) => void - palette: FloorplanPalette - unitsPerPixel: number -}) { - return ( - <> - {endpointHandles.map(({ wall, endpoint, point, isSelected, isActive }) => { - const endpointId = `${wall.id}:${endpoint}` - const isHovered = hoveredEndpointId === endpointId - const stroke = - isSelected || isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke - const hoverStroke = - isSelected || isActive - ? palette.endpointHandleActiveStroke - : palette.endpointHandleHoverStroke - const outerRadius = - (isActive - ? FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_RADIUS_PX - : isSelected - ? FLOORPLAN_ENDPOINT_HANDLE_SELECTED_RADIUS_PX - : FLOORPLAN_ENDPOINT_HANDLE_RADIUS_PX) * unitsPerPixel - const dotRadius = - (isActive - ? FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX - : FLOORPLAN_ENDPOINT_HANDLE_DOT_RADIUS_PX) * unitsPerPixel - const svgPoint = toSvgPlanPoint(point) - - return ( - { - event.stopPropagation() - }} - onPointerEnter={() => onEndpointHoverChange(endpointId)} - onPointerLeave={() => onEndpointHoverChange(null)} - > - - - - - onWallEndpointPointerDown(wall, endpoint, event)} - pointerEvents="all" - r={outerRadius} - stroke="transparent" - strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH} - style={{ cursor: EDITOR_CURSOR }} - vectorEffect="non-scaling-stroke" - /> - - ) - })} - - ) -}) - -const FloorplanFenceEndpointLayer = memo(function FloorplanFenceEndpointLayer({ - endpointHandles, - hoveredEndpointId, - onEndpointHoverChange, - onFenceEndpointPointerDown, - palette, - unitsPerPixel, -}: { - endpointHandles: Array<{ - fence: FenceNode - endpoint: WallEndpoint - point: WallPlanPoint - isActive: boolean - isSelected: boolean - }> - hoveredEndpointId: string | null - onEndpointHoverChange: (endpointId: string | null) => void - onFenceEndpointPointerDown: ( - fence: FenceNode, - endpoint: WallEndpoint, - event: ReactPointerEvent, - ) => void - palette: FloorplanPalette - unitsPerPixel: number -}) { - return ( - <> - {endpointHandles.map(({ fence, endpoint, point, isSelected, isActive }) => { - const endpointId = `${fence.id}:${endpoint}` - const isHovered = hoveredEndpointId === endpointId - const stroke = - isSelected || isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke - const hoverStroke = - isSelected || isActive - ? palette.endpointHandleActiveStroke - : palette.endpointHandleHoverStroke - const outerRadius = - (isActive - ? FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_RADIUS_PX - : isSelected - ? FLOORPLAN_ENDPOINT_HANDLE_SELECTED_RADIUS_PX - : FLOORPLAN_ENDPOINT_HANDLE_RADIUS_PX) * unitsPerPixel - const dotRadius = - (isActive - ? FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX - : FLOORPLAN_ENDPOINT_HANDLE_DOT_RADIUS_PX) * unitsPerPixel - const svgPoint = toSvgPlanPoint(point) - - return ( - { - event.stopPropagation() - }} - onPointerEnter={() => onEndpointHoverChange(endpointId)} - onPointerLeave={() => onEndpointHoverChange(null)} - > - - - - - onFenceEndpointPointerDown(fence, endpoint, event)} - pointerEvents="all" - r={outerRadius} - stroke="transparent" - strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH} - style={{ cursor: EDITOR_CURSOR }} - vectorEffect="non-scaling-stroke" - /> - - ) - })} - - ) -}) - -const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLayer({ - curveHandles, - hoveredHandleId, - onHandleHoverChange, - onWallCurvePointerDown, - palette, - unitsPerPixel, -}: { - curveHandles: Array<{ - wall: WallNode - point: WallPlanPoint - isActive: boolean - }> - hoveredHandleId: string | null - onHandleHoverChange: (handleId: string | null) => void - onWallCurvePointerDown: (wall: WallNode, event: ReactPointerEvent) => void - palette: FloorplanPalette - unitsPerPixel: number -}) { - return ( - <> - {curveHandles.map(({ wall, point, isActive }) => { - const handleId = `curve:${wall.id}` - const isHovered = hoveredHandleId === handleId - const stroke = palette.curveHandleStroke - const hoverStroke = palette.curveHandleHoverStroke - const svgPoint = toSvgPlanPoint(point) - const radius = - (isActive - ? FLOORPLAN_ENDPOINT_HANDLE_SELECTED_RADIUS_PX - : FLOORPLAN_ENDPOINT_HANDLE_RADIUS_PX) * unitsPerPixel - const dotRadius = FLOORPLAN_CURVE_HANDLE_DOT_RADIUS_PX * unitsPerPixel - - return ( - { - event.stopPropagation() - }} - onPointerEnter={() => onHandleHoverChange(handleId)} - onPointerLeave={() => onHandleHoverChange(null)} - > - - - - onWallCurvePointerDown(wall, event)} - pointerEvents="all" - r={radius} - stroke="transparent" - strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH} - style={{ cursor: EDITOR_CURSOR }} - vectorEffect="non-scaling-stroke" - /> - - ) - })} - - ) -}) - const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ edgeHandles = [], hoveredHandleId, @@ -8005,11 +3882,6 @@ export function FloorplanPanel() { const wallEndpointDragRef = useRef(null) const wallCurveDragRef = useRef(null) const siteBoundaryDraftRef = useRef(null) - const slabBoundaryDraftRef = useRef(null) - const slabHoleBoundaryDraftRef = useRef(null) - const ceilingBoundaryDraftRef = useRef(null) - const ceilingHoleBoundaryDraftRef = useRef(null) - const zoneBoundaryDraftRef = useRef(null) const gestureScaleRef = useRef(1) const panelInteractionRef = useRef(null) const panelBoundsRef = useRef(null) @@ -8098,28 +3970,6 @@ export function FloorplanPanel() { const [zoneDraftPoints, setZoneDraftPoints] = useState([]) const [siteBoundaryDraft, setSiteBoundaryDraft] = useState(null) const [siteVertexDragState, setSiteVertexDragState] = useState(null) - const [slabBoundaryDraft, setSlabBoundaryDraft] = useState(null) - const [slabVertexDragState, setSlabVertexDragState] = useState(null) - const [slabHoleBoundaryDraft, setSlabHoleBoundaryDraft] = useState( - null, - ) - const [slabHoleVertexDragState, setSlabHoleVertexDragState] = - useState(null) - const [slabHoleMoveDraft, setSlabHoleMoveDraft] = useState(null) - const [ceilingBoundaryDraft, setCeilingBoundaryDraft] = useState( - null, - ) - const [ceilingVertexDragState, setCeilingVertexDragState] = - useState(null) - const [ceilingHoleBoundaryDraft, setCeilingHoleBoundaryDraft] = - useState(null) - const [ceilingHoleVertexDragState, setCeilingHoleVertexDragState] = - useState(null) - const [ceilingHoleMoveDraft, setCeilingHoleMoveDraft] = useState( - null, - ) - const [zoneBoundaryDraft, setZoneBoundaryDraft] = useState(null) - const [zoneVertexDragState, setZoneVertexDragState] = useState(null) const [guideTransformDraft, setGuideTransformDraft] = useState(null) const [referenceScaleDraft, setReferenceScaleDraft] = useState(null) const [pendingReferenceScale, setPendingReferenceScale] = useState( @@ -8436,346 +4286,29 @@ export function FloorplanPanel() { return hasPreviewWalls ? nextFloorplanWallById : floorplanWallById }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft]) - const floorplanFenceEntries = useMemo( - () => - fences.flatMap((fence) => { - const live = useLiveTransforms.getState().get(fence.id) - const fenceCenterX = (fence.start[0] + fence.end[0]) / 2 - const fenceCenterZ = (fence.start[1] + fence.end[1]) / 2 - const displayFence = live - ? { - ...fence, - start: [ - fence.start[0] + (live.position[0] - fenceCenterX), - fence.start[1] + (live.position[2] - fenceCenterZ), - ] as typeof fence.start, - end: [ - fence.end[0] + (live.position[0] - fenceCenterX), - fence.end[1] + (live.position[2] - fenceCenterZ), - ] as typeof fence.end, - } - : fence - const centerline = isCurvedWall(displayFence) - ? sampleWallCenterline(displayFence, 24) - : [ - { x: displayFence.start[0], y: displayFence.start[1] }, - { x: displayFence.end[0], y: displayFence.end[1] }, - ] - const path = buildSvgPolylinePath(centerline) - if (!path) { - return [] - } + // Fence is fully registry-driven (`def.floorplan` + `buildFenceFloorplan`). + // The legacy entry list is permanently empty; kept as a typed stable + // reference so downstream prop sites stay typed without each having to + // declare its own `[]`. + const floorplanFenceEntries = useMemo(() => [], []) + // Wall is fully registry-driven. Empty stable arrays for the legacy + // entry lists; consumers' map / iteration paths become no-ops. + const wallPolygons = useMemo(() => [], []) + const displayWallPolygons = useMemo(() => [], []) - const markerFrames = getFloorplanFenceMarkerTs(displayFence).map((t) => { - const frame = getWallCurveFrameAt(displayFence, t) - - return { - angleDeg: (Math.atan2(frame.tangent.y, frame.tangent.x) * 180) / Math.PI, - point: frame.point, - } - }) - - return [{ fence: displayFence, centerline, markerFrames, path }] - }), - [fences, movingFloorplanNodeRevision], - ) - const wallPolygons = useMemo( - () => - walls.map((wall) => { - const floorplanWall = floorplanWallById.get(wall.id) ?? getFloorplanWall(wall) - const polygon = getWallPlanFootprint(floorplanWall, wallMiterData) - return { - points: formatPolygonPoints(polygon), - wall, - polygon, - } - }), - [floorplanWallById, wallMiterData, walls], - ) - const displayWallPolygons = useMemo(() => { - if (!(wallEndpointDraft || wallCurveDraft)) { - return wallPolygons - } - - const previewWalls = new Map() - - if (wallEndpointDraft) { - for (const draftUpdate of getWallEndpointDraftUpdates(wallEndpointDraft)) { - const previewWall = displayWallById.get(draftUpdate.id) - if (previewWall) { - previewWalls.set(previewWall.id, previewWall) - } - } - } - - if (wallCurveDraft) { - const previewWall = displayWallById.get(wallCurveDraft.wallId) - if (previewWall) { - previewWalls.set(previewWall.id, previewWall) - } - } - - if (previewWalls.size === 0) { - return wallPolygons - } - - return wallPolygons.map((entry) => - (() => { - const previewWall = previewWalls.get(entry.wall.id) - if (!previewWall) { - return entry - } - - const previewPolygon = getWallPlanFootprint( - getFloorplanWall(previewWall), - EMPTY_WALL_MITER_DATA, - ) - - return { - wall: previewWall, - polygon: previewPolygon, - points: formatPolygonPoints(previewPolygon), - } - })(), - ) - }, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons]) - - const openingsPolygons = useMemo( - () => - openings.flatMap((opening) => { - const wall = displayFloorplanWallById.get(opening.parentId as WallNode['id']) - if (!wall) return [] - const live = useLiveTransforms.getState().get(opening.id) - const displayOpening = - live && - (movingNode?.type === 'door' || movingNode?.type === 'window') && - movingNode.id === opening.id - ? { - ...opening, - position: [ - live.position[0], - opening.position[1], - live.position[2], - ] as typeof opening.position, - rotation: [ - opening.rotation[0], - live.rotation, - opening.rotation[2], - ] as typeof opening.rotation, - } - : opening - const polygon = getOpeningFootprint(wall, displayOpening) - return [ - { - opening: displayOpening, - points: formatPolygonPoints(polygon), - polygon, - }, - ] - }), - [displayFloorplanWallById, movingFloorplanNodeRevision, movingNode, openings], - ) - const slabPolygons = useMemo( - () => - slabs.flatMap((slab) => { - const polygon = toFloorplanPolygon(slab.polygon) - if (polygon.length < 3) { - return [] - } - - const holes = (slab.holes ?? []) - .map((hole) => toFloorplanPolygon(hole)) - .filter((hole) => hole.length >= 3) - const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab)) - const visualHoles = holes - - return [ - { - slab, - polygon, - holes, - visualPolygon, - visualHoles, - path: formatPolygonPath(visualPolygon, visualHoles), - }, - ] - }), - [slabs], - ) - const displaySlabPolygons = useMemo(() => { - if (!(slabBoundaryDraft || slabHoleBoundaryDraft || slabHoleMoveDraft)) { - return slabPolygons - } - - return slabPolygons.map((entry) => { - let nextEntry = entry - - if (slabBoundaryDraft && entry.slab.id === slabBoundaryDraft.slabId) { - nextEntry = (() => { - const draftVisualPolygon = - slabBoundaryDraft.visualOffsets?.length === slabBoundaryDraft.polygon.length - ? getDraftSlabVisualPolygon(slabBoundaryDraft) - : toFloorplanPolygon( - getRenderableSlabPolygon({ - ...entry.slab, - polygon: slabBoundaryDraft.polygon, - }), - ) - - return { - ...entry, - polygon: slabBoundaryDraft.polygon.map(toPoint2D), - visualPolygon: draftVisualPolygon, - path: formatPolygonPath(draftVisualPolygon, entry.visualHoles), - } - })() - } - - const activeHoleDraft = - slabHoleBoundaryDraft && entry.slab.id === slabHoleBoundaryDraft.slabId - ? slabHoleBoundaryDraft - : slabHoleMoveDraft && entry.slab.id === slabHoleMoveDraft.slabId - ? slabHoleMoveDraft - : null - - if (activeHoleDraft) { - const draftHole = activeHoleDraft.polygon.map(toPoint2D) - const draftHoles = nextEntry.holes.map((hole, index) => - index === activeHoleDraft.holeIndex ? draftHole : hole, - ) - const draftVisualHoles = nextEntry.visualHoles.map((hole, index) => - index === activeHoleDraft.holeIndex ? draftHole : hole, - ) - - nextEntry = { - ...nextEntry, - holes: draftHoles, - visualHoles: draftVisualHoles, - path: formatPolygonPath(nextEntry.visualPolygon, draftVisualHoles), - } - } - - return nextEntry - }) - }, [slabBoundaryDraft, slabHoleBoundaryDraft, slabHoleMoveDraft, slabPolygons]) - const ceilingPolygons = useMemo( - () => - ceilings.flatMap((ceiling) => { - const polygon = toFloorplanPolygon(ceiling.polygon) - if (polygon.length < 3) { - return [] - } - - const holes = (ceiling.holes ?? []) - .map((hole) => toFloorplanPolygon(hole)) - .filter((hole) => hole.length >= 3) - - return [ - { - ceiling, - polygon, - holes, - path: formatPolygonPath(polygon, holes), - }, - ] - }), - [ceilings], - ) - const displayCeilingPolygons = useMemo(() => { - if (!(ceilingBoundaryDraft || ceilingHoleBoundaryDraft || ceilingHoleMoveDraft)) { - return ceilingPolygons - } - - return ceilingPolygons.map((entry) => { - let nextEntry = entry - - if (ceilingBoundaryDraft && entry.ceiling.id === ceilingBoundaryDraft.ceilingId) { - const polygon = ceilingBoundaryDraft.polygon.map(toPoint2D) - nextEntry = { - ...entry, - polygon, - path: formatPolygonPath(polygon, entry.holes), - } - } - - const activeHoleDraft = - ceilingHoleBoundaryDraft && entry.ceiling.id === ceilingHoleBoundaryDraft.ceilingId - ? ceilingHoleBoundaryDraft - : ceilingHoleMoveDraft && entry.ceiling.id === ceilingHoleMoveDraft.ceilingId - ? ceilingHoleMoveDraft - : null - - if (activeHoleDraft) { - const draftHole = activeHoleDraft.polygon.map(toPoint2D) - const holes = nextEntry.holes.map((hole, index) => - index === activeHoleDraft.holeIndex ? draftHole : hole, - ) - - nextEntry = { - ...nextEntry, - holes, - path: formatPolygonPath(nextEntry.polygon, holes), - } - } - - return nextEntry - }) - }, [ceilingBoundaryDraft, ceilingHoleBoundaryDraft, ceilingHoleMoveDraft, ceilingPolygons]) - const zonePolygons = useMemo( - () => - zones.flatMap((zone) => { - const polygon = toFloorplanPolygon(zone.polygon) - if (polygon.length < 3) { - return [] - } - - return [ - { - zone, - polygon, - points: formatPolygonPoints(polygon), - }, - ] - }), - [zones], - ) - const displayZonePolygons = useMemo(() => { - if (!zoneBoundaryDraft) { - return zonePolygons - } - - return zonePolygons.map((entry) => - entry.zone.id === zoneBoundaryDraft.zoneId - ? { - ...entry, - polygon: zoneBoundaryDraft.polygon.map(toPoint2D), - points: formatPolygonPoints(zoneBoundaryDraft.polygon.map(toPoint2D)), - } - : entry, - ) - }, [zoneBoundaryDraft, zonePolygons]) - const floorplanColumnEntries = useMemo( - () => - levelDescendantNodes.flatMap((node) => { - if (!(node.type === 'column' && node.visible !== false)) { - return [] - } - - const polygon = getColumnPlanFootprint(node) - if (polygon.length < 3) { - return [] - } - - return [ - { - column: node, - points: formatPolygonPoints(polygon), - polygon, - }, - ] - }), - [levelDescendantNodes], - ) + // Doors + windows fully registry-driven via `def.floorplan`. + const openingsPolygons = useMemo(() => [], []) + // Slab + ceiling fully registry-driven via `def.floorplan`. Same + // empty-stable-array pattern. + const slabPolygons = useMemo(() => [], []) + const displaySlabPolygons = useMemo(() => [], []) + const ceilingPolygons = useMemo(() => [], []) + const displayCeilingPolygons = useMemo(() => [], []) + // Zone fully registry-driven via `def.floorplan`. + const zonePolygons = useMemo(() => [], []) + const displayZonePolygons = useMemo(() => [], []) + // Column fully registry-driven via `def.floorplan`. + const floorplanColumnEntries = useMemo(() => [], []) const levelDescendantNodeById = useMemo( () => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)), [levelDescendantNodes], @@ -8798,175 +4331,11 @@ export function FloorplanPanel() { ), [levelDescendantNodes], ) - const floorplanSpawnEntries = useMemo( - () => - spawns - .filter((spawn) => spawn.visible !== false) - .map((spawn) => { - const live = useLiveTransforms.getState().get(spawn.id) - - return { - spawn, - position: { - x: live?.position[0] ?? spawn.position[0], - y: live?.position[2] ?? spawn.position[2], - }, - rotation: live?.rotation ?? spawn.rotation, - } - }), - [movingFloorplanNodeRevision, spawns], - ) - const floorplanItemEntries = useMemo(() => { - const transformCache = new Map() - - return floorplanItems.flatMap((item) => { - const entry = buildFloorplanItemEntry(item, levelDescendantNodeById, transformCache) - if (!entry) { - return [] - } - - return [ - { - dimensionPolygon: entry.dimensionPolygon, - item: entry.item, - points: formatPolygonPoints(entry.polygon), - polygon: entry.polygon, - usesRealMesh: entry.usesRealMesh, - center: entry.center, - rotation: entry.rotation, - width: entry.width, - depth: entry.depth, - }, - ] - }) - }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) - const floorplanElevatorEntries = useMemo(() => { - // These keys subscribe the memo to imperative floorplan stores read with getState(). - void elevatorLiveOverrideKey - void elevatorRuntimeKey - void movingFloorplanNodeRevision - - if (!levelNode) { - return [] - } - - const nodes = useScene.getState().nodes - const interactiveElevators = useInteractive.getState().elevators - - return elevators.flatMap((elevator) => { - const liveOverrides = useLiveNodeOverrides.getState().get(elevator.id) - const displayElevator = liveOverrides - ? ({ ...elevator, ...liveOverrides } as ElevatorNode) - : elevator - const serviceLevelIds = resolveElevatorServiceLevelIds(displayElevator, nodes) - if (!serviceLevelIds.includes(levelNode.id)) { - return [] - } - - const live = useLiveTransforms.getState().get(displayElevator.id) - const position = live?.position ?? displayElevator.position - const rotation = live?.rotation ?? displayElevator.rotation - const center = { x: position[0], y: position[2] } - const wallThickness = Math.max(displayElevator.shaftWallThickness ?? 0.09, 0.04) - const cabWidth = Math.max(displayElevator.width, 0.8) - const cabDepth = Math.max(displayElevator.depth, 0.8) - const shaftWidth = Math.max( - displayElevator.shaftWidth ?? displayElevator.width, - cabWidth, - 0.8, - ) - const shaftDepth = Math.max( - displayElevator.shaftDepth ?? displayElevator.depth, - cabDepth, - 0.8, - ) - const doorWidth = Math.min( - Math.max(displayElevator.doorWidth, 0.45), - cabWidth - 0.18, - shaftWidth - 0.18, - ) - const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness) - const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness) - const footprintCorners: Array = [ - [-halfWidth, -halfDepth], - [halfWidth, -halfDepth], - [halfWidth, halfDepth], - [-halfWidth, halfDepth], - ] - const polygon = footprintCorners.map(([localX, localY]) => { - const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation) - return { - x: center.x + offsetX, - y: center.y + offsetY, - } - }) - const frontStart = polygon[0] - const frontEnd = polygon[1] - if (!(frontStart && frontEnd)) { - return [] - } - const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation) - const runtime = interactiveElevators[displayElevator.id] - const disabledLevelIds = new Set(displayElevator.disabledLevelIds ?? []) - const serviceOnlyLevelIds = new Set(displayElevator.serviceOnlyLevelIds ?? []) - const servedLevels = serviceLevelIds.flatMap((levelId) => { - const level = nodes[levelId as AnyNodeId] - if (level?.type !== 'level') { - return [] - } - - return [ - { - id: level.id, - isCurrent: runtime?.currentLevelId === level.id, - isDisabled: disabledLevelIds.has(level.id), - isQueued: runtime?.queue.includes(level.id) ?? false, - isServiceOnly: serviceOnlyLevelIds.has(level.id), - isTarget: runtime?.targetLevelId === level.id, - label: level.name || `L${level.level}`, - }, - ] - }) - - return [ - { - cabCenterLocalY: -shaftDepth / 2 + cabDepth / 2, - cabDepth, - cabWidth, - center, - doorStyle: displayElevator.doorStyle ?? 'center-opening', - doorWidth, - elevator: displayElevator, - frontEdge: { - start: frontStart, - end: frontEnd, - }, - frontNormal: { - x: frontNormalX, - y: frontNormalY, - }, - isCarOnLevel: runtime?.currentLevelId === levelNode.id, - isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false, - isTargetLevel: runtime?.targetLevelId === levelNode.id, - outerHalfDepth: halfDepth, - outerHalfWidth: halfWidth, - points: formatPolygonPoints(polygon), - polygon, - rotation, - servedLevels, - shaftDepth, - shaftWallThickness: wallThickness, - shaftWidth, - }, - ] - }) - }, [ - elevatorLiveOverrideKey, - elevatorRuntimeKey, - elevators, - levelNode, - movingFloorplanNodeRevision, - ]) + // Spawn + item fully registry-driven. + const floorplanSpawnEntries = useMemo(() => [], []) + const floorplanItemEntries = useMemo(() => [], []) + // Elevator fully registry-driven via `def.floorplan`. + const floorplanElevatorEntries = useMemo(() => [], []) const referenceFloorLevel = useMemo(() => { if (!(showReferenceFloor && levelNode)) { return null @@ -9161,489 +4530,32 @@ export function FloorplanPanel() { wallPolygons, } }, [referenceFloorDescendants, referenceFloorLevel]) - const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh) - const floorplanStairEntries = useMemo( - () => - floorplanStairs.flatMap((stair) => { - const displayStair = - movingNode?.type === 'stair' && movingNode.id === stair.id - ? (() => { - const live = useLiveTransforms.getState().get(stair.id) - const liveX = cursorPoint?.[0] ?? live?.position[0] ?? stair.position[0] - const liveZ = cursorPoint?.[1] ?? live?.position[2] ?? stair.position[2] - const liveRotation = live?.rotation ?? stair.rotation - - return { - ...stair, - position: [liveX, stair.position[1], liveZ] as StairNode['position'], - rotation: liveRotation, - } - })() - : stair - const segments = (displayStair.children ?? []) - .map((childId) => levelDescendantNodeById.get(childId as AnyNodeId)) - .filter( - (node): node is StairSegmentNode => - node?.type === 'stair-segment' && node.visible !== false, - ) - const entry = buildSharedFloorplanStairEntry(displayStair, segments) - if (!entry) { - return [] - } - const hitPolygons = - (displayStair.stairType ?? 'straight') === 'straight' - ? entry.segments.map((segmentEntry) => segmentEntry.polygon) - : [getFloorplanCurvedStairHitPolygon(displayStair)] - - return [ - { - ...entry, - hitPolygons, - segments: entry.segments.map((segmentEntry) => ({ - ...segmentEntry, - innerPoints: formatPolygonPoints(segmentEntry.innerPolygon), - points: formatPolygonPoints(segmentEntry.polygon), - treadBars: segmentEntry.treadBars.map((polygon) => ({ - points: formatPolygonPoints(polygon), - polygon, - })), - })), - }, - ] - }), - [ - cursorPoint, - floorplanStairs, - levelDescendantNodeById, - movingFloorplanNodeRevision, - movingNode, - ], - ) - const floorplanRoofEntries = useMemo( - () => - roofs.flatMap((roof) => { - const liveRoofTransform = - movingNode?.type === 'roof' && movingNode.id === roof.id - ? useLiveTransforms.getState().get(roof.id) - : null - const liveRoofPosition = liveRoofTransform - ? worldToBuildingLocalPlanPoint( - liveRoofTransform.position, - buildingPosition, - buildingRotationY, - ) - : null - const displayRoof = liveRoofTransform - ? { - ...roof, - position: [ - liveRoofPosition?.x ?? roof.position[0], - roof.position[1], - liveRoofPosition?.y ?? roof.position[2], - ] as RoofNode['position'], - rotation: liveRoofTransform.rotation, - } - : roof - const segments = (displayRoof.children ?? []) - .map((childId) => levelDescendantNodeById.get(childId as AnyNodeId)) - .filter( - (node): node is RoofSegmentNode => - node?.type === 'roof-segment' && node.visible !== false, - ) - .flatMap((segment) => { - const liveSegmentTransform = - movingNode?.type === 'roof-segment' && movingNode.id === segment.id - ? useLiveTransforms.getState().get(segment.id) - : null - const worldPositionOverride = liveSegmentTransform - ? worldToBuildingLocalPlanPoint( - liveSegmentTransform.position, - buildingPosition, - buildingRotationY, - ) - : undefined - const polygon = getRoofSegmentPolygon(displayRoof, segment, { - localRotation: liveSegmentTransform?.rotation, - worldPositionOverride, - }) - - if (polygon.length < 3) { - return [] - } - - return [ - { - segment, - polygon, - points: formatPolygonPoints(polygon), - ridgeLine: getRoofSegmentRidgeLine(displayRoof, segment, { - localRotation: liveSegmentTransform?.rotation, - worldPositionOverride, - }), - }, - ] - }) - - if (segments.length === 0) { - return [] - } - - return [ - { - roof: displayRoof, - center: { x: displayRoof.position[0], y: displayRoof.position[2] }, - segments, - }, - ] - }), - [ - buildingPosition, - buildingRotationY, - levelDescendantNodeById, - movingFloorplanNodeRevision, - movingNode, - roofs, - ], - ) - const selectedOpeningEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return openingsPolygons.find(({ opening }) => opening.id === selectedIds[0]) ?? null - }, [openingsPolygons, selectedIds]) - const selectedItemEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null - }, [floorplanItemEntries, selectedIds]) - const selectedSpawnEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null - }, [floorplanSpawnEntries, selectedIds]) - const selectedElevatorEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return floorplanElevatorEntries.find(({ elevator }) => elevator.id === selectedIds[0]) ?? null - }, [floorplanElevatorEntries, selectedIds]) - const selectedItemClearanceMeasurements = useMemo(() => { - if (!selectedItemEntry) { - return [] as LinearMeasurementOverlay[] - } - - const attachTo = selectedItemEntry.item.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return [] as LinearMeasurementOverlay[] - } - - const polygon = selectedItemEntry.polygon - if (polygon.length < 4 || displayWallPolygons.length === 0) { - return [] as LinearMeasurementOverlay[] - } - - const centroid = polygonCentroid(polygon) - - return polygon.flatMap((startPoint, index) => { - const endPoint = polygon[(index + 1) % polygon.length] - if (!endPoint) { - return [] - } - - const edgeVector = { - x: endPoint.x - startPoint.x, - y: endPoint.y - startPoint.y, - } - const tangent = normalizePlanVector(edgeVector) - if (!tangent) { - return [] - } - - let outwardNormal: Point2D = { - x: -tangent.y, - y: tangent.x, - } - const midpoint = { - x: (startPoint.x + endPoint.x) / 2, - y: (startPoint.y + endPoint.y) / 2, - } - const centroidVector = { - x: midpoint.x - centroid.x, - y: midpoint.y - centroid.y, - } - - if (dotPlanVectors(outwardNormal, centroidVector) < 0) { - outwardNormal = { - x: -outwardNormal.x, - y: -outwardNormal.y, - } - } - - let bestHit: { - point: Point2D - distance: number - } | null = null - - for (const { polygon: wallPolygon } of displayWallPolygons) { - for (let wallIndex = 0; wallIndex < wallPolygon.length; wallIndex += 1) { - const wallStart = wallPolygon[wallIndex] - const wallEnd = wallPolygon[(wallIndex + 1) % wallPolygon.length] - if (!(wallStart && wallEnd)) { - continue - } - - const wallEdgeVector = { - x: wallEnd.x - wallStart.x, - y: wallEnd.y - wallStart.y, - } - const wallTangent = normalizePlanVector(wallEdgeVector) - if (!wallTangent) { - continue - } - - if ( - Math.abs(dotPlanVectors(tangent, wallTangent)) < - FLOORPLAN_ITEM_CLEARANCE_EDGE_PARALLEL_THRESHOLD - ) { - continue - } - - const hit = getRaySegmentIntersection(midpoint, outwardNormal, wallStart, wallEnd) - if ( - !hit || - hit.rayDistance < FLOORPLAN_ITEM_CLEARANCE_MIN_DISTANCE || - hit.rayDistance > FLOORPLAN_ITEM_CLEARANCE_MAX_DISTANCE - ) { - continue - } - - if (!bestHit || hit.rayDistance < bestHit.distance) { - bestHit = { - point: hit.point, - distance: hit.rayDistance, - } - } - } - } - - if (!bestHit) { - return [] - } - - const overlay = getLinearMeasurementOverlay( - `${selectedItemEntry.item.id}:clearance:${index}`, - midpoint, - bestHit.point, - formatMeasurement(bestHit.distance, unit, calibratedMetersPerUnit), - { - extensionOvershoot: 0, - }, - ) - - return overlay ? [overlay] : [] - }) - }, [calibratedMetersPerUnit, displayWallPolygons, selectedItemEntry, unit]) - const movingOpeningPlacementMeasurements = useMemo(() => { - if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) { - return [] as LinearMeasurementOverlay[] - } - - const openingEntry = openingsPolygons.find(({ opening }) => opening.id === movingNode.id) - if (!openingEntry) { - return [] as LinearMeasurementOverlay[] - } - - const wallEntry = displayWallPolygons.find( - ({ wall }) => wall.id === openingEntry.opening.parentId, - ) - if (!wallEntry || isCurvedWall(wallEntry.wall)) { - return [] as LinearMeasurementOverlay[] - } - - const faceContext = getWallMeasurementFaceContext(wallEntry, displayWallPolygons) - if (!faceContext) { - return [] as LinearMeasurementOverlay[] - } - - const wall = wallEntry.wall - const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) - if (wallLength < 1e-6) { - return [] as LinearMeasurementOverlay[] - } - - const tangent = normalizePlanVector({ - x: wall.end[0] - wall.start[0], - y: wall.end[1] - wall.start[1], - }) - if (!tangent) { - return [] as LinearMeasurementOverlay[] - } - - const opening = openingEntry.opening - const startDistance = opening.position[0] - opening.width / 2 - const endDistance = opening.position[0] + opening.width / 2 - const { leftBoundary, rightBoundary } = getAdjacentOpeningBounds( - { - id: opening.id, - wallId: wall.id, - startDistance, - endDistance, - }, - openingsPolygons, - ) - const faceOffsetDistance = (wall.thickness ?? 0.1) / 2 - const openingFaceStart = { - x: - wall.start[0] + - tangent.x * startDistance + - faceContext.outwardNormal.x * faceOffsetDistance, - y: - wall.start[1] + - tangent.y * startDistance + - faceContext.outwardNormal.y * faceOffsetDistance, - } - const openingFaceEnd = { - x: wall.start[0] + tangent.x * endDistance + faceContext.outwardNormal.x * faceOffsetDistance, - y: wall.start[1] + tangent.y * endDistance + faceContext.outwardNormal.y * faceOffsetDistance, - } - const leftBoundaryPoint = - leftBoundary === null - ? faceContext.outerFace.start - : { - x: - wall.start[0] + - tangent.x * leftBoundary + - faceContext.outwardNormal.x * faceOffsetDistance, - y: - wall.start[1] + - tangent.y * leftBoundary + - faceContext.outwardNormal.y * faceOffsetDistance, - } - const rightBoundaryPoint = - rightBoundary === null - ? faceContext.outerFace.end - : { - x: - wall.start[0] + - tangent.x * rightBoundary + - faceContext.outwardNormal.x * faceOffsetDistance, - y: - wall.start[1] + - tangent.y * rightBoundary + - faceContext.outwardNormal.y * faceOffsetDistance, - } - const overlays: LinearMeasurementOverlay[] = [] - const leftDistance = getPlanPointDistance(leftBoundaryPoint, openingFaceStart) - - if (leftDistance >= 0.01) { - const overlay = getLinearMeasurementOverlay( - `${opening.id}:placement-left`, - leftBoundaryPoint, - openingFaceStart, - formatMeasurement(leftDistance, unit, calibratedMetersPerUnit), - { - offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, - offsetVector: faceContext.outwardNormal, - }, - ) - - if (overlay) { - overlays.push({ - ...overlay, - extensionStroke: FLOORPLAN_OPENING_MEASUREMENT_EXTENSION, - labelFill: FLOORPLAN_OPENING_MEASUREMENT_TEXT, - stroke: FLOORPLAN_OPENING_MEASUREMENT_STROKE, - }) - } - } - - const rightDistance = getPlanPointDistance(openingFaceEnd, rightBoundaryPoint) - - if (rightDistance >= 0.01) { - const overlay = getLinearMeasurementOverlay( - `${opening.id}:placement-right`, - openingFaceEnd, - rightBoundaryPoint, - formatMeasurement(rightDistance, unit, calibratedMetersPerUnit), - { - offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, - offsetVector: faceContext.outwardNormal, - }, - ) - - if (overlay) { - overlays.push({ - ...overlay, - extensionStroke: FLOORPLAN_OPENING_MEASUREMENT_EXTENSION, - labelFill: FLOORPLAN_OPENING_MEASUREMENT_TEXT, - stroke: FLOORPLAN_OPENING_MEASUREMENT_STROKE, - }) - } - } - - return overlays - }, [calibratedMetersPerUnit, displayWallPolygons, movingNode, openingsPolygons, unit]) - const selectedWallEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return displayWallPolygons.find(({ wall }) => wall.id === selectedIds[0]) ?? null - }, [displayWallPolygons, selectedIds]) - const selectedFenceEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return floorplanFenceEntries.find(({ fence }) => fence.id === selectedIds[0]) ?? null - }, [floorplanFenceEntries, selectedIds]) - const selectedStairEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return floorplanStairEntries.find(({ stair }) => stair.id === selectedIds[0]) ?? null - }, [floorplanStairEntries, selectedIds]) - const selectedRoofEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return floorplanRoofEntries.find(({ roof }) => roof.id === selectedIds[0]) ?? null - }, [floorplanRoofEntries, selectedIds]) + // Pending-mesh check was a flag the legacy active-level item entries + // raised when their polygon was the dimension fallback (waiting for + // the GLB to load to produce a tighter convex hull). Items are now + // registry-rendered, so the active-level entry list is always empty + // and this flag is permanently false. + const hasPendingItemMeshFootprints = false + // Stair fully registry-driven via `def.floorplan` (the parent walks + // its `stair-segment` children inside `buildStairFloorplan` to handle + // the cumulative-transform chain). `FloorplanRegistryLayer` renders + // the result; this legacy list stays empty. + const floorplanStairEntries = useMemo(() => [], []) + // Roof / roof-segment fully registry-driven via def.floorplan. + const floorplanRoofEntries = useMemo(() => [], []) + // Slab / ceiling / zone are registry-driven; the polygon-handle, hole + // editor, and boundary-edit affordances live on `def.floorplanAffordances`. + // These legacy lookups stay as null stubs so the hole-editing fallbacks + // that still reference them compile cleanly. + const selectedSlabEntry = null as SlabPolygonEntry | null + const selectedCeilingEntry = null as CeilingPolygonEntry | null + const selectedZoneEntry = null as ZonePolygonEntry | null const slabById = useMemo(() => new Map(slabs.map((slab) => [slab.id, slab] as const)), [slabs]) const zoneById = useMemo(() => new Map(zones.map((zone) => [zone.id, zone] as const)), [zones]) const ceilingById = useMemo( () => new Map(ceilings.map((ceiling) => [ceiling.id, ceiling] as const)), [ceilings], ) - const selectedSlabEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null - }, [displaySlabPolygons, selectedIds]) - const selectedCeilingEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return displayCeilingPolygons.find(({ ceiling }) => ceiling.id === selectedIds[0]) ?? null - }, [displayCeilingPolygons, selectedIds]) - const selectedZoneEntry = useMemo(() => { - if (!selectedZoneId) { - return null - } - - return displayZonePolygons.find(({ zone }) => zone.id === selectedZoneId) ?? null - }, [displayZonePolygons, selectedZoneId]) const isSiteEditActive = phase === 'site' const isWallBuildActive = phase === 'structure' && mode === 'build' && tool === 'wall' @@ -9675,6 +4587,12 @@ export function FloorplanPanel() { (mode === 'build' && tool === 'item') || movingNode?.type === 'item' const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo + // Any registry-driven kind whose tool is currently active. Lets the floor + // plan emit `grid:click` / `grid:move` events to that kind's placement tool + // (shelf today; future Phase 5 kinds the moment they register a `tool`). + // Independent of whether the kind has a `def.floorplan` builder — placement + // works as long as the kind's tool subscribes to the emitter. + const isRegistryToolBuildActive = mode === 'build' && tool != null && nodeRegistry.has(tool) const isFloorplanGridInteractionActive = isFenceBuildActive || isRoofBuildActive || @@ -9692,7 +4610,8 @@ export function FloorplanPanel() { isFenceCurveActive || isFenceEndpointMoveActive || isFloorItemBuildActive || - isFloorItemMoveActive + isFloorItemMoveActive || + isRegistryToolBuildActive const floorplanPreviewStairSegment = useMemo( () => StairSegmentNodeSchema.parse({ @@ -9832,51 +4751,7 @@ export function FloorplanPanel() { !movingFenceEndpoint && isFloorplanItemContextActive const visibleSitePolygon = phase === 'site' ? displaySitePolygon : null - const selectedSlabEditingHoleIndex = - selectedSlabEntry && editingHole?.nodeId === selectedSlabEntry.slab.id - ? editingHole.holeIndex - : null - const selectedSlabEditingHole = - selectedSlabEditingHoleIndex !== null - ? (selectedSlabEntry?.holes[selectedSlabEditingHoleIndex] ?? null) - : null - const selectedCeilingEditingHoleIndex = - selectedCeilingEntry && editingHole?.nodeId === selectedCeilingEntry.ceiling.id - ? editingHole.holeIndex - : null - const selectedCeilingEditingHole = - selectedCeilingEditingHoleIndex !== null - ? (selectedCeilingEntry?.holes[selectedCeilingEditingHoleIndex] ?? null) - : null const shouldShowSiteBoundaryHandles = isSiteEditActive && visibleSitePolygon !== null - const shouldShowSlabBoundaryHandles = - mode === 'select' && - !movingNode && - floorplanSelectionTool === 'click' && - selectedSlabEntry !== null && - selectedSlabEditingHole === null - const shouldShowCeilingBoundaryHandles = - mode === 'select' && - !movingNode && - floorplanSelectionTool === 'click' && - selectedCeilingEntry !== null && - selectedCeilingEditingHole === null - const shouldShowSlabHoleBoundaryHandles = - mode === 'select' && - !movingNode && - floorplanSelectionTool === 'click' && - selectedSlabEntry !== null && - selectedSlabEditingHole !== null && - slabHoleMoveDraft === null - const shouldShowCeilingHoleBoundaryHandles = - mode === 'select' && - !movingNode && - floorplanSelectionTool === 'click' && - selectedCeilingEntry !== null && - selectedCeilingEditingHole !== null && - ceilingHoleMoveDraft === null - const shouldShowZoneBoundaryHandles = canSelectFloorplanZones && selectedZoneEntry !== null - const showZonePolygons = true // Zone polygons always visible (labels always clickable) const visibleZonePolygons = displayZonePolygons const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) const highlightedFloorplanIdSet = useMemo( @@ -9912,411 +4787,6 @@ export function FloorplanPanel() { return toSvgSelectionBounds(visibleMarqueeBounds) }, [visibleMarqueeBounds]) - const wallEndpointHandles = useMemo(() => { - if (isOpeningPlacementActive || movingNode) { - return [] - } - - return displayWallPolygons.flatMap(({ wall }) => { - const isSelected = selectedIdSet.has(wall.id) - const isVisible = isSelected || wallEndpointDraft?.wallId === wall.id - if (!isVisible) { - return [] - } - - return (['start', 'end'] as const).map((endpoint) => ({ - wall, - endpoint, - point: endpoint === 'start' ? wall.start : wall.end, - isSelected, - isActive: wallEndpointDraft?.wallId === wall.id && wallEndpointDraft.endpoint === endpoint, - })) - }) - }, [displayWallPolygons, isOpeningPlacementActive, movingNode, selectedIdSet, wallEndpointDraft]) - const fenceEndpointHandles = useMemo(() => { - if ( - isOpeningPlacementActive || - movingNode || - isFenceCurveActive || - mode !== 'select' || - floorplanSelectionTool !== 'click' || - !selectedFenceEntry - ) { - return [] - } - - return (['start', 'end'] as const).map((endpoint) => ({ - fence: selectedFenceEntry.fence, - endpoint, - point: endpoint === 'start' ? selectedFenceEntry.fence.start : selectedFenceEntry.fence.end, - isSelected: true, - isActive: - movingFenceEndpoint?.fence.id === selectedFenceEntry.fence.id && - movingFenceEndpoint.endpoint === endpoint, - })) - }, [ - floorplanSelectionTool, - isFenceCurveActive, - isOpeningPlacementActive, - mode, - movingFenceEndpoint, - movingNode, - selectedFenceEntry, - ]) - const wallCurveHandles = useMemo(() => { - if ( - isOpeningPlacementActive || - movingNode || - mode !== 'select' || - floorplanSelectionTool !== 'click' || - !selectedWallEntry - ) { - return [] - } - - const hasWallChildrenBlockingCurve = (selectedWallEntry.wall.children ?? []).some((childId) => { - const childNode = levelDescendantNodeById.get(childId as AnyNodeId) - if (!childNode) { - return false - } - - if (childNode.type === 'door' || childNode.type === 'window') { - return true - } - - if (childNode.type === 'item') { - const attachTo = childNode.asset?.attachTo - return attachTo === 'wall' || attachTo === 'wall-side' - } - - return false - }) - if (hasWallChildrenBlockingCurve) { - return [] - } - - const centerPoint = getWallMidpointHandlePoint(selectedWallEntry.wall) - - return [ - { - wall: selectedWallEntry.wall, - point: [centerPoint.x, centerPoint.y] as WallPlanPoint, - isActive: wallCurveDraft?.wallId === selectedWallEntry.wall.id, - }, - ] - }, [ - floorplanSelectionTool, - isOpeningPlacementActive, - mode, - movingNode, - levelDescendantNodeById, - selectedWallEntry, - wallCurveDraft, - ]) - const canCurveSelectedWall = wallCurveHandles.length > 0 - const slabVertexHandles = useMemo(() => { - if (!shouldShowSlabBoundaryHandles) { - return [] - } - - const rawPolygon = selectedSlabEntry.polygon - - return getSlabHandlePolygon(selectedSlabEntry).map((point) => { - const vertexIndex = getClosestPolygonVertexIndex(point, rawPolygon) - - return { - nodeId: selectedSlabEntry.slab.id, - vertexIndex, - point: toWallPlanPoint(point), - isActive: - slabVertexDragState?.slabId === selectedSlabEntry.slab.id && - slabVertexDragState.vertexIndex === vertexIndex, - } - }) - }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) - const slabMidpointHandles = useMemo(() => { - if (!(shouldShowSlabBoundaryHandles && !slabVertexDragState)) { - return [] - } - - const handlePolygon = getSlabHandlePolygon(selectedSlabEntry) - - return handlePolygon.map((point, edgeIndex, polygon) => { - const nextPoint = polygon[(edgeIndex + 1) % polygon.length] - const midpoint = { - x: (point.x + (nextPoint?.x ?? point.x)) / 2, - y: (point.y + (nextPoint?.y ?? point.y)) / 2, - } - - return { - nodeId: selectedSlabEntry.slab.id, - edgeIndex, - point: [midpoint.x, midpoint.y] as WallPlanPoint, - } - }) - }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) - const slabEdgeHandles = useMemo(() => { - if (!shouldShowSlabBoundaryHandles) { - return [] - } - - const handlePolygon = getSlabHandlePolygon(selectedSlabEntry).map(toWallPlanPoint) - - return handlePolygon.flatMap((start, edgeIndex, polygon) => { - const end = polygon[(edgeIndex + 1) % polygon.length] - if (!end) { - return [] - } - - return [ - { - nodeId: selectedSlabEntry.slab.id, - edgeIndex, - start, - end, - isActive: - slabVertexDragState?.slabId === selectedSlabEntry.slab.id && - slabVertexDragState.mode === 'edge' && - slabVertexDragState.edgeIndex === edgeIndex, - }, - ] - }) - }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) - const ceilingVertexHandles = useMemo(() => { - if (!shouldShowCeilingBoundaryHandles) { - return [] - } - - return selectedCeilingEntry.polygon.map((point, vertexIndex) => ({ - nodeId: selectedCeilingEntry.ceiling.id, - vertexIndex, - point: toWallPlanPoint(point), - isActive: - ceilingVertexDragState?.ceilingId === selectedCeilingEntry.ceiling.id && - ceilingVertexDragState.vertexIndex === vertexIndex, - })) - }, [ceilingVertexDragState, selectedCeilingEntry, shouldShowCeilingBoundaryHandles]) - const ceilingMidpointHandles = useMemo(() => { - if (!(shouldShowCeilingBoundaryHandles && !ceilingVertexDragState)) { - return [] - } - - return selectedCeilingEntry.polygon.map((point, edgeIndex, polygon) => { - const nextPoint = polygon[(edgeIndex + 1) % polygon.length] - - return { - nodeId: selectedCeilingEntry.ceiling.id, - edgeIndex, - point: [ - (point.x + (nextPoint?.x ?? point.x)) / 2, - (point.y + (nextPoint?.y ?? point.y)) / 2, - ] as WallPlanPoint, - } - }) - }, [ceilingVertexDragState, selectedCeilingEntry, shouldShowCeilingBoundaryHandles]) - const ceilingEdgeHandles = useMemo(() => { - if (!shouldShowCeilingBoundaryHandles) { - return [] - } - - return selectedCeilingEntry.polygon.flatMap((point, edgeIndex, polygon) => { - const nextPoint = polygon[(edgeIndex + 1) % polygon.length] - if (!nextPoint) { - return [] - } - - return [ - { - nodeId: selectedCeilingEntry.ceiling.id, - edgeIndex, - start: toWallPlanPoint(point), - end: toWallPlanPoint(nextPoint), - isActive: - ceilingVertexDragState?.ceilingId === selectedCeilingEntry.ceiling.id && - ceilingVertexDragState.mode === 'edge' && - ceilingVertexDragState.edgeIndex === edgeIndex, - }, - ] - }) - }, [ceilingVertexDragState, selectedCeilingEntry, shouldShowCeilingBoundaryHandles]) - const slabHoleVertexHandles = useMemo(() => { - if ( - !( - shouldShowSlabHoleBoundaryHandles && - selectedSlabEntry && - selectedSlabEditingHole && - selectedSlabEditingHoleIndex !== null - ) - ) { - return [] - } - - return selectedSlabEditingHole.map((point, vertexIndex) => ({ - nodeId: selectedSlabEntry.slab.id, - vertexIndex, - point: toWallPlanPoint(point), - isActive: - slabHoleVertexDragState?.slabId === selectedSlabEntry.slab.id && - slabHoleVertexDragState.holeIndex === selectedSlabEditingHoleIndex && - slabHoleVertexDragState.vertexIndex === vertexIndex, - })) - }, [ - selectedSlabEditingHole, - selectedSlabEditingHoleIndex, - selectedSlabEntry, - shouldShowSlabHoleBoundaryHandles, - slabHoleVertexDragState, - ]) - const slabHoleMidpointHandles = useMemo(() => { - if ( - !( - shouldShowSlabHoleBoundaryHandles && - selectedSlabEntry && - selectedSlabEditingHole && - !slabHoleVertexDragState - ) - ) { - return [] - } - - return selectedSlabEditingHole.map((point, edgeIndex, polygon) => { - const nextPoint = polygon[(edgeIndex + 1) % polygon.length] - - return { - nodeId: selectedSlabEntry.slab.id, - edgeIndex, - point: [ - (point.x + (nextPoint?.x ?? point.x)) / 2, - (point.y + (nextPoint?.y ?? point.y)) / 2, - ] as WallPlanPoint, - } - }) - }, [ - selectedSlabEditingHole, - selectedSlabEntry, - shouldShowSlabHoleBoundaryHandles, - slabHoleVertexDragState, - ]) - const slabHoleEdgeHandles = useMemo(() => { - if (!(shouldShowSlabHoleBoundaryHandles && selectedSlabEntry && selectedSlabEditingHole)) { - return [] - } - - return selectedSlabEditingHole.flatMap((point, edgeIndex, polygon) => { - const nextPoint = polygon[(edgeIndex + 1) % polygon.length] - if (!nextPoint) { - return [] - } - - return [ - { - nodeId: selectedSlabEntry.slab.id, - edgeIndex, - start: toWallPlanPoint(point), - end: toWallPlanPoint(nextPoint), - isActive: - slabHoleVertexDragState?.slabId === selectedSlabEntry.slab.id && - slabHoleVertexDragState.mode === 'edge' && - slabHoleVertexDragState.edgeIndex === edgeIndex, - }, - ] - }) - }, [ - selectedSlabEditingHole, - selectedSlabEntry, - shouldShowSlabHoleBoundaryHandles, - slabHoleVertexDragState, - ]) - const ceilingHoleVertexHandles = useMemo(() => { - if ( - !( - shouldShowCeilingHoleBoundaryHandles && - selectedCeilingEntry && - selectedCeilingEditingHole && - selectedCeilingEditingHoleIndex !== null - ) - ) { - return [] - } - - return selectedCeilingEditingHole.map((point, vertexIndex) => ({ - nodeId: selectedCeilingEntry.ceiling.id, - vertexIndex, - point: toWallPlanPoint(point), - isActive: - ceilingHoleVertexDragState?.ceilingId === selectedCeilingEntry.ceiling.id && - ceilingHoleVertexDragState.holeIndex === selectedCeilingEditingHoleIndex && - ceilingHoleVertexDragState.vertexIndex === vertexIndex, - })) - }, [ - ceilingHoleVertexDragState, - selectedCeilingEditingHole, - selectedCeilingEditingHoleIndex, - selectedCeilingEntry, - shouldShowCeilingHoleBoundaryHandles, - ]) - const ceilingHoleMidpointHandles = useMemo(() => { - if ( - !( - shouldShowCeilingHoleBoundaryHandles && - selectedCeilingEntry && - selectedCeilingEditingHole && - !ceilingHoleVertexDragState - ) - ) { - return [] - } - - return selectedCeilingEditingHole.map((point, edgeIndex, polygon) => { - const nextPoint = polygon[(edgeIndex + 1) % polygon.length] - - return { - nodeId: selectedCeilingEntry.ceiling.id, - edgeIndex, - point: [ - (point.x + (nextPoint?.x ?? point.x)) / 2, - (point.y + (nextPoint?.y ?? point.y)) / 2, - ] as WallPlanPoint, - } - }) - }, [ - ceilingHoleVertexDragState, - selectedCeilingEditingHole, - selectedCeilingEntry, - shouldShowCeilingHoleBoundaryHandles, - ]) - const ceilingHoleEdgeHandles = useMemo(() => { - if ( - !(shouldShowCeilingHoleBoundaryHandles && selectedCeilingEntry && selectedCeilingEditingHole) - ) { - return [] - } - - return selectedCeilingEditingHole.flatMap((point, edgeIndex, polygon) => { - const nextPoint = polygon[(edgeIndex + 1) % polygon.length] - if (!nextPoint) { - return [] - } - - return [ - { - nodeId: selectedCeilingEntry.ceiling.id, - edgeIndex, - start: toWallPlanPoint(point), - end: toWallPlanPoint(nextPoint), - isActive: - ceilingHoleVertexDragState?.ceilingId === selectedCeilingEntry.ceiling.id && - ceilingHoleVertexDragState.mode === 'edge' && - ceilingHoleVertexDragState.edgeIndex === edgeIndex, - }, - ] - }) - }, [ - ceilingHoleVertexDragState, - selectedCeilingEditingHole, - selectedCeilingEntry, - shouldShowCeilingHoleBoundaryHandles, - ]) const siteVertexHandles = useMemo(() => { if (!(shouldShowSiteBoundaryHandles && visibleSitePolygon)) { return [] @@ -10348,37 +4818,6 @@ export function FloorplanPanel() { } }) }, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon]) - const zoneVertexHandles = useMemo(() => { - if (!shouldShowZoneBoundaryHandles) { - return [] - } - - return selectedZoneEntry.polygon.map((point, vertexIndex) => ({ - nodeId: selectedZoneEntry.zone.id, - vertexIndex, - point: toWallPlanPoint(point), - isActive: - zoneVertexDragState?.zoneId === selectedZoneEntry.zone.id && - zoneVertexDragState.vertexIndex === vertexIndex, - })) - }, [selectedZoneEntry, shouldShowZoneBoundaryHandles, zoneVertexDragState]) - const zoneMidpointHandles = useMemo(() => { - if (!(shouldShowZoneBoundaryHandles && !zoneVertexDragState)) { - return [] - } - - return selectedZoneEntry.polygon.map((point, edgeIndex, polygon) => { - const nextPoint = polygon[(edgeIndex + 1) % polygon.length] - return { - nodeId: selectedZoneEntry.zone.id, - edgeIndex, - point: [ - (point.x + (nextPoint?.x ?? point.x)) / 2, - (point.y + (nextPoint?.y ?? point.y)) / 2, - ] as WallPlanPoint, - } - }) - }, [selectedZoneEntry, shouldShowZoneBoundaryHandles, zoneVertexDragState]) const draftPolygon = useMemo(() => { if (!(levelId && draftStart && draftEnd && isWallLongEnough(draftStart, draftEnd))) { @@ -10611,14 +5050,7 @@ export function FloorplanPanel() { movingFenceEndpoint != null || curvingWall != null || curvingFence != null || - ceilingVertexDragState != null || - ceilingHoleMoveDraft != null || - ceilingHoleVertexDragState != null || - slabHoleMoveDraft != null || - slabHoleVertexDragState != null || - slabVertexDragState != null || siteVertexDragState != null || - zoneVertexDragState != null || isPolygonDraftBuildActive if (!(hasUserAdjustedViewportRef.current || transientFloorplanFit)) { @@ -10635,14 +5067,7 @@ export function FloorplanPanel() { levelId, movingFenceEndpoint, movingNode, - ceilingVertexDragState, - ceilingHoleMoveDraft, - ceilingHoleVertexDragState, siteVertexDragState, - slabHoleMoveDraft, - slabHoleVertexDragState, - slabVertexDragState, - zoneVertexDragState, ]) const viewBox = useMemo(() => { @@ -10683,192 +5108,6 @@ export function FloorplanPanel() { () => Math.max(floorplanWorldUnitsPerPixel * 0.55, 0.0001), [floorplanWorldUnitsPerPixel], ) - const selectedOpeningActionMenuPosition = useMemo( - () => - selectedOpeningEntry - ? getFloorplanActionMenuPosition( - selectedOpeningEntry.polygon, - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - : null, - [floorplanSceneRotationDeg, selectedOpeningEntry, surfaceSize, viewBox], - ) - const selectedItemActionMenuPosition = useMemo( - () => - selectedItemEntry - ? getFloorplanActionMenuPosition( - selectedItemEntry.polygon, - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - : null, - [floorplanSceneRotationDeg, selectedItemEntry, surfaceSize, viewBox], - ) - const selectedSpawnActionMenuPosition = useMemo(() => { - if (!selectedSpawnEntry) { - return null - } - - const { position } = selectedSpawnEntry - const svg = svgRef.current - const scene = floorplanSceneRef.current - const sceneCtm = scene?.getScreenCTM() - const hasResolvedSceneRotation = Number.isFinite(floorplanSceneRotationDeg) - - if (svg && scene && sceneCtm && hasResolvedSceneRotation) { - const svgRect = svg.getBoundingClientRect() - const svgPoint = svg.createSVGPoint() - svgPoint.x = toSvgX(position.x) - svgPoint.y = toSvgY(position.y) - FLOORPLAN_SPAWN_HIT_RADIUS - - const screenPoint = svgPoint.matrixTransform(sceneCtm) - const anchorX = screenPoint.x - svgRect.left - const anchorY = screenPoint.y - svgRect.top - - return { - x: Math.min( - Math.max(anchorX, FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING), - surfaceSize.width - FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING, - ), - y: Math.max(anchorY, FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y), - } - } - - return getFloorplanActionMenuPosition( - [ - { x: position.x - FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y - FLOORPLAN_SPAWN_HIT_RADIUS }, - { x: position.x + FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y - FLOORPLAN_SPAWN_HIT_RADIUS }, - { x: position.x + FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y + FLOORPLAN_SPAWN_HIT_RADIUS }, - { x: position.x - FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y + FLOORPLAN_SPAWN_HIT_RADIUS }, - ], - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - }, [floorplanSceneRotationDeg, selectedSpawnEntry, surfaceSize, viewBox]) - const selectedElevatorActionMenuPosition = useMemo( - () => - selectedElevatorEntry - ? getFloorplanActionMenuPosition( - selectedElevatorEntry.polygon, - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - : null, - [floorplanSceneRotationDeg, selectedElevatorEntry, surfaceSize, viewBox], - ) - const selectedSlabActionMenuPosition = useMemo(() => { - if (slabHoleMoveDraft) { - return null - } - - if (selectedSlabEditingHole) { - return getFloorplanActionMenuPosition( - selectedSlabEditingHole, - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - } - - return selectedSlabEntry - ? getFloorplanActionMenuPosition( - getSlabHandlePolygon(selectedSlabEntry), - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - : null - }, [ - floorplanSceneRotationDeg, - selectedSlabEditingHole, - selectedSlabEntry, - slabHoleMoveDraft, - surfaceSize, - viewBox, - ]) - const selectedCeilingActionMenuPosition = useMemo(() => { - if (ceilingHoleMoveDraft) { - return null - } - - if (selectedCeilingEditingHole) { - return getFloorplanActionMenuPosition( - selectedCeilingEditingHole, - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - } - - return selectedCeilingEntry - ? getFloorplanActionMenuPosition( - selectedCeilingEntry.polygon, - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - : null - }, [ - ceilingHoleMoveDraft, - floorplanSceneRotationDeg, - selectedCeilingEditingHole, - selectedCeilingEntry, - surfaceSize, - viewBox, - ]) - const selectedWallActionMenuPosition = useMemo( - () => - selectedWallEntry - ? getFloorplanActionMenuPosition( - selectedWallEntry.polygon, - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - : null, - [floorplanSceneRotationDeg, selectedWallEntry, surfaceSize, viewBox], - ) - const selectedFenceActionMenuPosition = useMemo( - () => - selectedFenceEntry - ? getFloorplanActionMenuPosition( - selectedFenceEntry.centerline, - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - : null, - [floorplanSceneRotationDeg, selectedFenceEntry, surfaceSize, viewBox], - ) - const selectedStairActionMenuPosition = useMemo( - () => - selectedStairEntry - ? getFloorplanActionMenuPosition( - selectedStairEntry.hitPolygons.flat(), - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - : null, - [floorplanSceneRotationDeg, selectedStairEntry, surfaceSize, viewBox], - ) - const selectedRoofActionMenuPosition = useMemo( - () => - selectedRoofEntry - ? getFloorplanActionMenuPosition( - selectedRoofEntry.segments.flatMap(({ polygon }) => polygon), - viewBox, - surfaceSize, - floorplanSceneRotationDeg, - ) - : null, - [floorplanSceneRotationDeg, selectedRoofEntry, surfaceSize, viewBox], - ) const floorplanCursorAnchorPosition = useMemo(() => { if ( cursorPoint && @@ -11111,6 +5350,29 @@ export function FloorplanPanel() { [theme], ) const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${theme}`, [theme]) + // Subset of the legacy palette surfaced to registry-driven kinds via + // . Mirrors `FloorplanPalette` in `@pascal-app/ + // core` — keep slot names + meanings in sync. + const floorplanRegistryPalette = useMemo( + () => ({ + selectedStroke: palette.selectedStroke, + selectedFill: palette.selectedFill, + selectedHatch: palette.selectedStroke, + wallHoverStroke: palette.wallHoverStroke, + endpointHandleFill: palette.endpointHandleFill, + endpointHandleStroke: palette.endpointHandleStroke, + endpointHandleHoverStroke: palette.endpointHandleHoverStroke, + endpointHandleActiveFill: palette.endpointHandleActiveFill, + endpointHandleActiveStroke: palette.endpointHandleActiveStroke, + curveHandleFill: palette.curveHandleFill, + curveHandleStroke: palette.curveHandleStroke, + curveHandleHoverStroke: palette.curveHandleHoverStroke, + measurementStroke: palette.measurementStroke, + measurementLabelBackground: theme === 'dark' ? '#0f172a' : '#ffffff', + measurementLabelText: theme === 'dark' ? '#e2e8f0' : '#171717', + }), + [palette, theme], + ) const slabSelectionHatchId = useMemo(() => `floorplan-slab-selection-hatch-${theme}`, [theme]) const gridSteps = useMemo( () => getVisibleGridSteps(viewBox.width, surfaceSize.width), @@ -11445,26 +5707,6 @@ export function FloorplanPanel() { siteBoundaryDraftRef.current = siteBoundaryDraft }, [siteBoundaryDraft]) - useEffect(() => { - slabBoundaryDraftRef.current = slabBoundaryDraft - }, [slabBoundaryDraft]) - - useEffect(() => { - slabHoleBoundaryDraftRef.current = slabHoleBoundaryDraft - }, [slabHoleBoundaryDraft]) - - useEffect(() => { - ceilingBoundaryDraftRef.current = ceilingBoundaryDraft - }, [ceilingBoundaryDraft]) - - useEffect(() => { - ceilingHoleBoundaryDraftRef.current = ceilingHoleBoundaryDraft - }, [ceilingHoleBoundaryDraft]) - - useEffect(() => { - zoneBoundaryDraftRef.current = zoneBoundaryDraft - }, [zoneBoundaryDraft]) - useEffect(() => { guideTransformDraftRef.current = guideTransformDraft }, [guideTransformDraft]) @@ -11694,35 +5936,6 @@ export function FloorplanPanel() { setSiteBoundaryDraft(null) setHoveredSiteHandleId(null) }, []) - const clearSlabBoundaryInteraction = useCallback(() => { - setSlabVertexDragState(null) - setSlabBoundaryDraft(null) - setHoveredSlabHandleId(null) - document.body.style.cursor = '' - }, []) - const clearSlabHoleBoundaryInteraction = useCallback(() => { - setSlabHoleVertexDragState(null) - setSlabHoleBoundaryDraft(null) - setHoveredSlabHandleId(null) - document.body.style.cursor = '' - }, []) - const clearCeilingBoundaryInteraction = useCallback(() => { - setCeilingVertexDragState(null) - setCeilingBoundaryDraft(null) - setHoveredCeilingHandleId(null) - document.body.style.cursor = '' - }, []) - const clearCeilingHoleBoundaryInteraction = useCallback(() => { - setCeilingHoleVertexDragState(null) - setCeilingHoleBoundaryDraft(null) - setHoveredCeilingHandleId(null) - document.body.style.cursor = '' - }, []) - const clearZoneBoundaryInteraction = useCallback(() => { - setZoneVertexDragState(null) - setZoneBoundaryDraft(null) - setHoveredZoneHandleId(null) - }, []) const clearDraft = useCallback(() => { clearWallPlacementDraft() @@ -11734,20 +5947,14 @@ export function FloorplanPanel() { clearWallEndpointDrag() clearWallCurveDrag() clearSiteBoundaryInteraction() - clearSlabBoundaryInteraction() - clearCeilingBoundaryInteraction() - clearZoneBoundaryInteraction() setCursorPoint(null) }, [ - clearCeilingBoundaryInteraction, clearFencePlacementDraft, clearCeilingPlacementDraft, clearRoofPlacementDraft, clearWallCurveDrag, clearSiteBoundaryInteraction, - clearSlabBoundaryInteraction, clearSlabPlacementDraft, - clearZoneBoundaryInteraction, clearWallEndpointDrag, clearWallPlacementDraft, clearZonePlacementDraft, @@ -12434,46 +6641,6 @@ export function FloorplanPanel() { clearSiteBoundaryInteraction() }, [clearSiteBoundaryInteraction, shouldShowSiteBoundaryHandles]) - useEffect(() => { - if (shouldShowSlabBoundaryHandles) { - return - } - - clearSlabBoundaryInteraction() - }, [clearSlabBoundaryInteraction, shouldShowSlabBoundaryHandles]) - - useEffect(() => { - if (shouldShowCeilingBoundaryHandles) { - return - } - - clearCeilingBoundaryInteraction() - }, [clearCeilingBoundaryInteraction, shouldShowCeilingBoundaryHandles]) - - useEffect(() => { - if (shouldShowSlabHoleBoundaryHandles) { - return - } - - clearSlabHoleBoundaryInteraction() - }, [clearSlabHoleBoundaryInteraction, shouldShowSlabHoleBoundaryHandles]) - - useEffect(() => { - if (shouldShowCeilingHoleBoundaryHandles) { - return - } - - clearCeilingHoleBoundaryInteraction() - }, [clearCeilingHoleBoundaryInteraction, shouldShowCeilingHoleBoundaryHandles]) - - useEffect(() => { - if (shouldShowZoneBoundaryHandles) { - return - } - - clearZoneBoundaryInteraction() - }, [clearZoneBoundaryInteraction, shouldShowZoneBoundaryHandles]) - useEffect(() => { const dragState = siteVertexDragState if (!dragState) { @@ -12578,773 +6745,6 @@ export function FloorplanPanel() { updateNode, ]) - useEffect(() => { - const dragState = slabVertexDragState - if (!dragState) { - return - } - - const handleWindowPointerMove = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - event.preventDefault() - - const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) - if (!planPoint) { - return - } - - const snappedHandlePoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] - setCursorPoint(snappedHandlePoint) - const snappedPoint: WallPlanPoint = [ - snappedHandlePoint[0] - dragState.visualOffset.x, - snappedHandlePoint[1] - dragState.visualOffset.y, - ] - - setSlabBoundaryDraft((currentDraft) => { - if (!currentDraft || currentDraft.slabId !== dragState.slabId) { - return currentDraft - } - - if ( - dragState.mode === 'edge' && - dragState.edgeIndex !== undefined && - dragState.edgeNormal && - dragState.initialPlanPoint && - dragState.initialPolygon - ) { - const nextPolygon = moveFloorplanPolygonEdge( - dragState.initialPolygon, - dragState.edgeIndex, - dragState.edgeNormal, - dragState.initialPlanPoint, - snappedPoint, - ) - - if (polygonsEqual(currentDraft.polygon, nextPolygon)) { - return currentDraft - } - - sfxEmitter.emit('sfx:grid-snap') - return { - ...currentDraft, - polygon: nextPolygon, - } - } - - const currentPoint = currentDraft.polygon[dragState.vertexIndex] - if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { - return currentDraft - } - - sfxEmitter.emit('sfx:grid-snap') - - const nextPolygon = [...currentDraft.polygon] - nextPolygon[dragState.vertexIndex] = snappedPoint - - return { - ...currentDraft, - polygon: nextPolygon, - } - }) - } - - const commitSlabVertexDrag = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - const draft = slabBoundaryDraftRef.current - const slab = slabById.get(dragState.slabId) - if (draft && slab && !polygonsEqual(draft.polygon, slab.polygon)) { - const suppressClick = (clickEvent: MouseEvent) => { - clickEvent.stopImmediatePropagation() - clickEvent.preventDefault() - window.removeEventListener('click', suppressClick, true) - } - window.addEventListener('click', suppressClick, true) - requestAnimationFrame(() => { - window.removeEventListener('click', suppressClick, true) - }) - - updateNode(draft.slabId, { - polygon: draft.polygon, - }) - sfxEmitter.emit('sfx:structure-build') - } - - clearSlabBoundaryInteraction() - setCursorPoint(null) - } - - const cancelSlabVertexDrag = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - clearSlabBoundaryInteraction() - setCursorPoint(null) - } - - window.addEventListener('pointermove', handleWindowPointerMove) - window.addEventListener('pointerup', commitSlabVertexDrag) - window.addEventListener('pointercancel', cancelSlabVertexDrag) - - return () => { - window.removeEventListener('pointermove', handleWindowPointerMove) - window.removeEventListener('pointerup', commitSlabVertexDrag) - window.removeEventListener('pointercancel', cancelSlabVertexDrag) - } - }, [ - clearSlabBoundaryInteraction, - getPlanPointFromClientPoint, - slabById, - slabVertexDragState, - updateNode, - ]) - - useEffect(() => { - const dragState = ceilingVertexDragState - if (!dragState) { - return - } - - const handleWindowPointerMove = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - event.preventDefault() - - const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) - if (!planPoint) { - return - } - - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] - setCursorPoint(snappedPoint) - - setCeilingBoundaryDraft((currentDraft) => { - if (!currentDraft || currentDraft.ceilingId !== dragState.ceilingId) { - return currentDraft - } - - if ( - dragState.mode === 'edge' && - dragState.edgeIndex !== undefined && - dragState.edgeNormal && - dragState.initialPlanPoint && - dragState.initialPolygon - ) { - const nextPolygon = moveFloorplanPolygonEdge( - dragState.initialPolygon, - dragState.edgeIndex, - dragState.edgeNormal, - dragState.initialPlanPoint, - snappedPoint, - ) - - if (polygonsEqual(currentDraft.polygon, nextPolygon)) { - return currentDraft - } - - sfxEmitter.emit('sfx:grid-snap') - return { - ...currentDraft, - polygon: nextPolygon, - } - } - - const currentPoint = currentDraft.polygon[dragState.vertexIndex] - if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { - return currentDraft - } - - sfxEmitter.emit('sfx:grid-snap') - - const nextPolygon = [...currentDraft.polygon] - nextPolygon[dragState.vertexIndex] = snappedPoint - - return { - ...currentDraft, - polygon: nextPolygon, - } - }) - } - - const commitCeilingVertexDrag = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - const draft = ceilingBoundaryDraftRef.current - const ceiling = ceilingById.get(dragState.ceilingId) - if (draft && ceiling && !polygonsEqual(draft.polygon, ceiling.polygon)) { - const suppressClick = (clickEvent: MouseEvent) => { - clickEvent.stopImmediatePropagation() - clickEvent.preventDefault() - window.removeEventListener('click', suppressClick, true) - } - window.addEventListener('click', suppressClick, true) - requestAnimationFrame(() => { - window.removeEventListener('click', suppressClick, true) - }) - - updateNode(draft.ceilingId, { - polygon: draft.polygon, - }) - sfxEmitter.emit('sfx:structure-build') - } - - clearCeilingBoundaryInteraction() - setCursorPoint(null) - } - - const cancelCeilingVertexDrag = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - clearCeilingBoundaryInteraction() - setCursorPoint(null) - } - - window.addEventListener('pointermove', handleWindowPointerMove) - window.addEventListener('pointerup', commitCeilingVertexDrag) - window.addEventListener('pointercancel', cancelCeilingVertexDrag) - - return () => { - window.removeEventListener('pointermove', handleWindowPointerMove) - window.removeEventListener('pointerup', commitCeilingVertexDrag) - window.removeEventListener('pointercancel', cancelCeilingVertexDrag) - } - }, [ - ceilingById, - ceilingVertexDragState, - clearCeilingBoundaryInteraction, - getPlanPointFromClientPoint, - updateNode, - ]) - - useEffect(() => { - const dragState = slabHoleVertexDragState - if (!dragState) { - return - } - - const handleWindowPointerMove = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - event.preventDefault() - - const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) - if (!planPoint) { - return - } - - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] - setCursorPoint(snappedPoint) - - setSlabHoleBoundaryDraft((currentDraft) => { - if ( - !currentDraft || - currentDraft.slabId !== dragState.slabId || - currentDraft.holeIndex !== dragState.holeIndex - ) { - return currentDraft - } - - if ( - dragState.mode === 'edge' && - dragState.edgeIndex !== undefined && - dragState.edgeNormal && - dragState.initialPlanPoint && - dragState.initialPolygon - ) { - const nextPolygon = moveFloorplanPolygonEdge( - dragState.initialPolygon, - dragState.edgeIndex, - dragState.edgeNormal, - dragState.initialPlanPoint, - snappedPoint, - ) - - if (polygonsEqual(currentDraft.polygon, nextPolygon)) { - return currentDraft - } - - sfxEmitter.emit('sfx:grid-snap') - return { - ...currentDraft, - polygon: nextPolygon, - } - } - - const currentPoint = currentDraft.polygon[dragState.vertexIndex] - if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { - return currentDraft - } - - sfxEmitter.emit('sfx:grid-snap') - - const nextPolygon = [...currentDraft.polygon] - nextPolygon[dragState.vertexIndex] = snappedPoint - - return { - ...currentDraft, - polygon: nextPolygon, - } - }) - } - - const commitSlabHoleVertexDrag = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - const draft = slabHoleBoundaryDraftRef.current - const slab = slabById.get(dragState.slabId) - const currentHole = slab?.holes?.[dragState.holeIndex] - if (draft && slab && currentHole && !polygonsEqual(draft.polygon, currentHole)) { - const suppressClick = (clickEvent: MouseEvent) => { - clickEvent.stopImmediatePropagation() - clickEvent.preventDefault() - window.removeEventListener('click', suppressClick, true) - } - window.addEventListener('click', suppressClick, true) - requestAnimationFrame(() => { - window.removeEventListener('click', suppressClick, true) - }) - - const nextHoles = [...(slab.holes ?? [])] - nextHoles[draft.holeIndex] = draft.polygon - updateNode(draft.slabId, { - holes: nextHoles, - }) - sfxEmitter.emit('sfx:structure-build') - } - - clearSlabHoleBoundaryInteraction() - setCursorPoint(null) - } - - const cancelSlabHoleVertexDrag = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - clearSlabHoleBoundaryInteraction() - setCursorPoint(null) - } - - window.addEventListener('pointermove', handleWindowPointerMove) - window.addEventListener('pointerup', commitSlabHoleVertexDrag) - window.addEventListener('pointercancel', cancelSlabHoleVertexDrag) - - return () => { - window.removeEventListener('pointermove', handleWindowPointerMove) - window.removeEventListener('pointerup', commitSlabHoleVertexDrag) - window.removeEventListener('pointercancel', cancelSlabHoleVertexDrag) - } - }, [ - clearSlabHoleBoundaryInteraction, - getPlanPointFromClientPoint, - slabById, - slabHoleVertexDragState, - updateNode, - ]) - - useEffect(() => { - const moveDraft = slabHoleMoveDraft - if (!moveDraft) { - return - } - - const updateMoveDraft = (clientX: number, clientY: number) => { - const planPoint = getPlanPointFromClientPoint(clientX, clientY) - if (!planPoint) { - return - } - - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] - const deltaX = snappedPoint[0] - moveDraft.startPlanPoint[0] - const deltaY = snappedPoint[1] - moveDraft.startPlanPoint[1] - const nextPolygon = moveDraft.originalPolygon.map( - ([x, y]) => [x + deltaX, y + deltaY] as WallPlanPoint, - ) - - setCursorPoint(snappedPoint) - setSlabHoleMoveDraft((currentDraft) => - currentDraft && - currentDraft.slabId === moveDraft.slabId && - currentDraft.holeIndex === moveDraft.holeIndex - ? { - ...currentDraft, - polygon: nextPolygon, - } - : currentDraft, - ) - } - - const commitSlabHoleMove = (event: PointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - - const slab = slabById.get(moveDraft.slabId) - const currentHole = slab?.holes?.[moveDraft.holeIndex] - if (slab && currentHole && !polygonsEqual(moveDraft.polygon, currentHole)) { - const nextHoles = [...(slab.holes ?? [])] - nextHoles[moveDraft.holeIndex] = moveDraft.polygon - updateNode(moveDraft.slabId, { - holes: nextHoles, - }) - sfxEmitter.emit('sfx:structure-build') - } - - setSlabHoleMoveDraft(null) - setCursorPoint(null) - } - - const cancelSlabHoleMove = (event: KeyboardEvent) => { - if (event.key !== 'Escape') { - return - } - - event.preventDefault() - setSlabHoleMoveDraft(null) - setCursorPoint(null) - } - - const handleWindowPointerMove = (event: PointerEvent) => { - updateMoveDraft(event.clientX, event.clientY) - } - - window.addEventListener('pointermove', handleWindowPointerMove) - window.addEventListener('pointerdown', commitSlabHoleMove, true) - window.addEventListener('keydown', cancelSlabHoleMove) - - return () => { - window.removeEventListener('pointermove', handleWindowPointerMove) - window.removeEventListener('pointerdown', commitSlabHoleMove, true) - window.removeEventListener('keydown', cancelSlabHoleMove) - } - }, [getPlanPointFromClientPoint, slabById, slabHoleMoveDraft, updateNode]) - - useEffect(() => { - const dragState = ceilingHoleVertexDragState - if (!dragState) { - return - } - - const handleWindowPointerMove = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - event.preventDefault() - - const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) - if (!planPoint) { - return - } - - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] - setCursorPoint(snappedPoint) - - setCeilingHoleBoundaryDraft((currentDraft) => { - if ( - !currentDraft || - currentDraft.ceilingId !== dragState.ceilingId || - currentDraft.holeIndex !== dragState.holeIndex - ) { - return currentDraft - } - - if ( - dragState.mode === 'edge' && - dragState.edgeIndex !== undefined && - dragState.edgeNormal && - dragState.initialPlanPoint && - dragState.initialPolygon - ) { - const nextPolygon = moveFloorplanPolygonEdge( - dragState.initialPolygon, - dragState.edgeIndex, - dragState.edgeNormal, - dragState.initialPlanPoint, - snappedPoint, - ) - - if (polygonsEqual(currentDraft.polygon, nextPolygon)) { - return currentDraft - } - - sfxEmitter.emit('sfx:grid-snap') - return { - ...currentDraft, - polygon: nextPolygon, - } - } - - const currentPoint = currentDraft.polygon[dragState.vertexIndex] - if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { - return currentDraft - } - - sfxEmitter.emit('sfx:grid-snap') - - const nextPolygon = [...currentDraft.polygon] - nextPolygon[dragState.vertexIndex] = snappedPoint - - return { - ...currentDraft, - polygon: nextPolygon, - } - }) - } - - const commitCeilingHoleVertexDrag = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - const draft = ceilingHoleBoundaryDraftRef.current - const ceiling = ceilingById.get(dragState.ceilingId) - const currentHole = ceiling?.holes?.[dragState.holeIndex] - if (draft && ceiling && currentHole && !polygonsEqual(draft.polygon, currentHole)) { - const suppressClick = (clickEvent: MouseEvent) => { - clickEvent.stopImmediatePropagation() - clickEvent.preventDefault() - window.removeEventListener('click', suppressClick, true) - } - window.addEventListener('click', suppressClick, true) - requestAnimationFrame(() => { - window.removeEventListener('click', suppressClick, true) - }) - - const nextHoles = [...(ceiling.holes ?? [])] - nextHoles[draft.holeIndex] = draft.polygon - updateNode(draft.ceilingId, { - holes: nextHoles, - }) - sfxEmitter.emit('sfx:structure-build') - } - - clearCeilingHoleBoundaryInteraction() - setCursorPoint(null) - } - - const cancelCeilingHoleVertexDrag = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - clearCeilingHoleBoundaryInteraction() - setCursorPoint(null) - } - - window.addEventListener('pointermove', handleWindowPointerMove) - window.addEventListener('pointerup', commitCeilingHoleVertexDrag) - window.addEventListener('pointercancel', cancelCeilingHoleVertexDrag) - - return () => { - window.removeEventListener('pointermove', handleWindowPointerMove) - window.removeEventListener('pointerup', commitCeilingHoleVertexDrag) - window.removeEventListener('pointercancel', cancelCeilingHoleVertexDrag) - } - }, [ - ceilingById, - ceilingHoleVertexDragState, - clearCeilingHoleBoundaryInteraction, - getPlanPointFromClientPoint, - updateNode, - ]) - - useEffect(() => { - const moveDraft = ceilingHoleMoveDraft - if (!moveDraft) { - return - } - - const updateMoveDraft = (clientX: number, clientY: number) => { - const planPoint = getPlanPointFromClientPoint(clientX, clientY) - if (!planPoint) { - return - } - - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] - const deltaX = snappedPoint[0] - moveDraft.startPlanPoint[0] - const deltaY = snappedPoint[1] - moveDraft.startPlanPoint[1] - const nextPolygon = moveDraft.originalPolygon.map( - ([x, y]) => [x + deltaX, y + deltaY] as WallPlanPoint, - ) - - setCursorPoint(snappedPoint) - setCeilingHoleMoveDraft((currentDraft) => - currentDraft && - currentDraft.ceilingId === moveDraft.ceilingId && - currentDraft.holeIndex === moveDraft.holeIndex - ? { - ...currentDraft, - polygon: nextPolygon, - } - : currentDraft, - ) - } - - const commitCeilingHoleMove = (event: PointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - - const ceiling = ceilingById.get(moveDraft.ceilingId) - const currentHole = ceiling?.holes?.[moveDraft.holeIndex] - if (ceiling && currentHole && !polygonsEqual(moveDraft.polygon, currentHole)) { - const nextHoles = [...(ceiling.holes ?? [])] - nextHoles[moveDraft.holeIndex] = moveDraft.polygon - updateNode(moveDraft.ceilingId, { - holes: nextHoles, - }) - sfxEmitter.emit('sfx:structure-build') - } - - setCeilingHoleMoveDraft(null) - setCursorPoint(null) - } - - const cancelCeilingHoleMove = (event: KeyboardEvent) => { - if (event.key !== 'Escape') { - return - } - - event.preventDefault() - setCeilingHoleMoveDraft(null) - setCursorPoint(null) - } - - const handleWindowPointerMove = (event: PointerEvent) => { - updateMoveDraft(event.clientX, event.clientY) - } - - window.addEventListener('pointermove', handleWindowPointerMove) - window.addEventListener('pointerdown', commitCeilingHoleMove, true) - window.addEventListener('keydown', cancelCeilingHoleMove) - - return () => { - window.removeEventListener('pointermove', handleWindowPointerMove) - window.removeEventListener('pointerdown', commitCeilingHoleMove, true) - window.removeEventListener('keydown', cancelCeilingHoleMove) - } - }, [ceilingById, ceilingHoleMoveDraft, getPlanPointFromClientPoint, updateNode]) - - useEffect(() => { - const dragState = zoneVertexDragState - if (!dragState) { - return - } - - const handleWindowPointerMove = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - event.preventDefault() - - const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) - if (!planPoint) { - return - } - - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] - setCursorPoint(snappedPoint) - - setZoneBoundaryDraft((currentDraft) => { - if (!currentDraft || currentDraft.zoneId !== dragState.zoneId) { - return currentDraft - } - - const currentPoint = currentDraft.polygon[dragState.vertexIndex] - if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { - return currentDraft - } - - sfxEmitter.emit('sfx:grid-snap') - - const nextPolygon = [...currentDraft.polygon] - nextPolygon[dragState.vertexIndex] = snappedPoint - - return { - ...currentDraft, - polygon: nextPolygon, - } - }) - } - - const commitZoneVertexDrag = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - const draft = zoneBoundaryDraftRef.current - const zone = zoneById.get(dragState.zoneId) - if (draft && zone && !polygonsEqual(draft.polygon, zone.polygon)) { - const suppressClick = (clickEvent: MouseEvent) => { - clickEvent.stopImmediatePropagation() - clickEvent.preventDefault() - window.removeEventListener('click', suppressClick, true) - } - window.addEventListener('click', suppressClick, true) - requestAnimationFrame(() => { - window.removeEventListener('click', suppressClick, true) - }) - - updateNode(draft.zoneId, { - polygon: draft.polygon, - }) - sfxEmitter.emit('sfx:structure-build') - } - - clearZoneBoundaryInteraction() - setCursorPoint(null) - } - - const cancelZoneVertexDrag = (event: PointerEvent) => { - if (event.pointerId !== dragState.pointerId) { - return - } - - clearZoneBoundaryInteraction() - setCursorPoint(null) - } - - window.addEventListener('pointermove', handleWindowPointerMove) - window.addEventListener('pointerup', commitZoneVertexDrag) - window.addEventListener('pointercancel', cancelZoneVertexDrag) - - return () => { - window.removeEventListener('pointermove', handleWindowPointerMove) - window.removeEventListener('pointerup', commitZoneVertexDrag) - window.removeEventListener('pointercancel', cancelZoneVertexDrag) - } - }, [ - clearZoneBoundaryInteraction, - getPlanPointFromClientPoint, - updateNode, - zoneById, - zoneVertexDragState, - ]) - useEffect(() => { return () => { setFloorplanHovered(false) @@ -13466,38 +6866,10 @@ export function FloorplanPanel() { return } - if (ceilingHoleMoveDraft) { - return - } - - if (ceilingHoleVertexDragState?.pointerId === event.pointerId) { - return - } - - if (ceilingVertexDragState?.pointerId === event.pointerId) { - return - } - - if (slabHoleMoveDraft) { - return - } - - if (slabHoleVertexDragState?.pointerId === event.pointerId) { - return - } - - if (slabVertexDragState?.pointerId === event.pointerId) { - return - } - if (siteVertexDragState?.pointerId === event.pointerId) { return } - if (zoneVertexDragState?.pointerId === event.pointerId) { - return - } - const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) if (!planPoint) { return @@ -13575,14 +6947,11 @@ export function FloorplanPanel() { return } - if (isFloorplanGridInteractionActive) { - const snappedPoint = emitFloorplanGridEvent('move', planPoint, event) - setCursorPoint((previousPoint) => - previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, - ) - return - } - + // Slab / zone polygon build — local draft state + grid emit, same + // reordering rationale as `handleBackgroundPlacementClick`: must + // run BEFORE the `isFloorplanGridInteractionActive` catch-all so + // the local polygon-draft state actually updates as the cursor + // moves (the catch-all would otherwise swallow the move event). if (isPolygonBuildActive) { const snappedPoint = snapPolygonDraftPoint({ point: planPoint, @@ -13590,6 +6959,10 @@ export function FloorplanPanel() { angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, }) + // Emit `grid:move` so the registry-driven slab tool also tracks + // the cursor (its 3D preview needs it). + emitFloorplanGridEvent('move', snappedPoint, event) + setCursorPoint((previousPoint) => { const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint)) if (hasChanged && activePolygonDraftPoints.length > 0) { @@ -13600,6 +6973,19 @@ export function FloorplanPanel() { return } + // Wall build also needs to run before the catch-all — see the + // wall branch in `handleBackgroundPlacementClick` for the same + // restructuring. The wall branch lives further below in this + // handler (`if (!isWallBuildActive) ... setDraftEnd(...)`); the + // grid emit is inlined there. + if (!isWallBuildActive && isFloorplanGridInteractionActive) { + const snappedPoint = emitFloorplanGridEvent('move', planPoint, event) + setCursorPoint((previousPoint) => + previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, + ) + return + } + if (isOpeningPlacementActive) { const closest = findClosestWallPoint(planPoint, walls, { canUseWall: (wall) => !isCurvedWall(wall), @@ -13656,6 +7042,10 @@ export function FloorplanPanel() { angleSnap: Boolean(draftStart) && !shiftPressed, }) + // Emit `grid:move` so the registry-driven wall tool's 3D preview + // tracks the cursor. The local draftEnd update below is what + // drives the 2D draft polygon — both views update in parallel. + emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint(snappedPoint) if (!draftStart) { @@ -13695,14 +7085,8 @@ export function FloorplanPanel() { isWallBuildActive, referenceScaleDraft, roofDraftStart, - ceilingHoleMoveDraft, - ceilingHoleVertexDragState, - ceilingVertexDragState, elevatorResizeDragState, siteVertexDragState, - slabHoleMoveDraft, - slabHoleVertexDragState, - slabVertexDragState, shiftPressed, surfaceSize.height, surfaceSize.width, @@ -13711,7 +7095,6 @@ export function FloorplanPanel() { viewBox.width, viewport, walls, - zoneVertexDragState, ], ) @@ -14177,351 +7560,6 @@ export function FloorplanPanel() { [setPreviewSelectedIds], ) - const syncDeleteHoveredId = useCallback( - (nodeId: string | null) => { - if (!isDeleteMode) { - return - } - - useViewer.getState().setHoveredId(nodeId as AnyNodeId | null) - }, - [isDeleteMode], - ) - - const handleWallHoverChange = useCallback( - (wallId: WallNode['id'] | null) => { - setHoveredWallId(wallId) - syncDeleteHoveredId(wallId) - }, - [syncDeleteHoveredId], - ) - const handleFenceHoverChange = useCallback( - (fenceId: FenceNode['id'] | null) => { - setHoveredFenceId(fenceId) - syncDeleteHoveredId(fenceId) - }, - [syncDeleteHoveredId], - ) - - const handleOpeningHoverChange = useCallback( - (openingId: OpeningNode['id'] | null) => { - setHoveredOpeningId(openingId) - syncDeleteHoveredId(openingId) - }, - [syncDeleteHoveredId], - ) - - const handleSlabHoverChange = useCallback( - (slabId: SlabNode['id'] | null) => { - setHoveredSlabId(slabId) - syncDeleteHoveredId(slabId) - }, - [syncDeleteHoveredId], - ) - - const handleCeilingHoverChange = useCallback( - (ceilingId: CeilingNode['id'] | null) => { - setHoveredCeilingId(ceilingId) - syncDeleteHoveredId(ceilingId) - }, - [syncDeleteHoveredId], - ) - - const handleItemHoverChange = useCallback( - (itemId: ItemNode['id'] | null) => { - setHoveredItemId(itemId) - syncDeleteHoveredId(itemId) - }, - [syncDeleteHoveredId], - ) - - const handleSpawnHoverChange = useCallback( - (spawnId: SpawnNode['id'] | null) => { - setHoveredSpawnId(spawnId) - syncDeleteHoveredId(spawnId) - }, - [syncDeleteHoveredId], - ) - - const handleStairHoverChange = useCallback( - (stairId: StairNode['id'] | null) => { - setHoveredStairId(stairId) - syncDeleteHoveredId(stairId) - }, - [syncDeleteHoveredId], - ) - - const handleElevatorHoverChange = useCallback( - (elevatorId: ElevatorNode['id'] | null) => { - setHoveredElevatorId(elevatorId) - syncDeleteHoveredId(elevatorId) - }, - [syncDeleteHoveredId], - ) - - const handleZoneHoverChange = useCallback( - (zoneId: ZoneNodeType['id'] | null) => { - setHoveredZoneId(zoneId) - syncDeleteHoveredId(zoneId) - }, - [syncDeleteHoveredId], - ) - const handleFloorplanItemHoverEnter = useCallback( - (itemId: ItemNode['id']) => { - handleFenceHoverChange(null) - handleOpeningHoverChange(null) - handleWallHoverChange(null) - handleSlabHoverChange(null) - handleCeilingHoverChange(null) - handleStairHoverChange(null) - handleElevatorHoverChange(null) - handleSpawnHoverChange(null) - handleZoneHoverChange(null) - handleItemHoverChange(itemId) - }, - [ - handleElevatorHoverChange, - handleFenceHoverChange, - handleItemHoverChange, - handleOpeningHoverChange, - handleCeilingHoverChange, - handleSlabHoverChange, - handleSpawnHoverChange, - handleStairHoverChange, - handleWallHoverChange, - handleZoneHoverChange, - ], - ) - const handleFloorplanFenceHoverEnter = useCallback( - (fenceId: FenceNode['id']) => { - handleItemHoverChange(null) - handleOpeningHoverChange(null) - handleWallHoverChange(null) - handleSlabHoverChange(null) - handleCeilingHoverChange(null) - handleStairHoverChange(null) - handleElevatorHoverChange(null) - handleSpawnHoverChange(null) - handleZoneHoverChange(null) - handleFenceHoverChange(fenceId) - }, - [ - handleElevatorHoverChange, - handleFenceHoverChange, - handleItemHoverChange, - handleOpeningHoverChange, - handleCeilingHoverChange, - handleSlabHoverChange, - handleSpawnHoverChange, - handleStairHoverChange, - handleWallHoverChange, - handleZoneHoverChange, - ], - ) - const handleFloorplanStairHoverEnter = useCallback( - (stairId: StairNode['id']) => { - handleItemHoverChange(null) - handleFenceHoverChange(null) - handleOpeningHoverChange(null) - handleSlabHoverChange(null) - handleCeilingHoverChange(null) - handleWallHoverChange(null) - handleSpawnHoverChange(null) - handleElevatorHoverChange(null) - handleZoneHoverChange(null) - handleStairHoverChange(stairId) - }, - [ - handleElevatorHoverChange, - handleFenceHoverChange, - handleItemHoverChange, - handleOpeningHoverChange, - handleCeilingHoverChange, - handleSlabHoverChange, - handleSpawnHoverChange, - handleStairHoverChange, - handleWallHoverChange, - handleZoneHoverChange, - ], - ) - const handleFloorplanSpawnHoverEnter = useCallback( - (spawnId: SpawnNode['id']) => { - handleItemHoverChange(null) - handleFenceHoverChange(null) - handleOpeningHoverChange(null) - handleSlabHoverChange(null) - handleCeilingHoverChange(null) - handleWallHoverChange(null) - handleStairHoverChange(null) - handleElevatorHoverChange(null) - handleZoneHoverChange(null) - handleSpawnHoverChange(spawnId) - }, - [ - handleCeilingHoverChange, - handleElevatorHoverChange, - handleFenceHoverChange, - handleItemHoverChange, - handleOpeningHoverChange, - handleSlabHoverChange, - handleSpawnHoverChange, - handleStairHoverChange, - handleWallHoverChange, - handleZoneHoverChange, - ], - ) - const handleFloorplanElevatorHoverEnter = useCallback( - (elevatorId: ElevatorNode['id']) => { - handleItemHoverChange(null) - handleFenceHoverChange(null) - handleOpeningHoverChange(null) - handleSlabHoverChange(null) - handleCeilingHoverChange(null) - handleWallHoverChange(null) - handleStairHoverChange(null) - handleSpawnHoverChange(null) - handleZoneHoverChange(null) - handleElevatorHoverChange(elevatorId) - }, - [ - handleCeilingHoverChange, - handleElevatorHoverChange, - handleFenceHoverChange, - handleItemHoverChange, - handleOpeningHoverChange, - handleSlabHoverChange, - handleSpawnHoverChange, - handleStairHoverChange, - handleWallHoverChange, - handleZoneHoverChange, - ], - ) - - const handleWallSelect = useCallback( - (wall: WallNode) => { - commitFloorplanSelection([wall.id]) - }, - [commitFloorplanSelection], - ) - - const handleWallClick = useCallback( - (wall: WallNode, event: ReactMouseEvent) => { - const centerX = (wall.start[0] + wall.end[0]) / 2 - const centerZ = (wall.start[1] + wall.end[1]) / 2 - const halfLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) / 2 - const localY = isOpeningPlacementActive ? floorplanOpeningLocalY : 0 - - setSelectedReferenceId(null) - emitter.emit('wall:click', { - node: wall, - position: [centerX, 0, centerZ], - localPosition: [halfLength, localY, 0], - stopPropagation: () => event.stopPropagation(), - nativeEvent: event.nativeEvent as any, - } as any) - }, - [floorplanOpeningLocalY, isOpeningPlacementActive, setSelectedReferenceId], - ) - - const handleWallDoubleClick = useCallback( - (wall: WallNode, event: ReactMouseEvent) => { - const centerX = (wall.start[0] + wall.end[0]) / 2 - const centerZ = (wall.start[1] + wall.end[1]) / 2 - const halfLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) / 2 - - emitter.emit('wall:double-click', { - node: wall, - position: [centerX, 0, centerZ], - localPosition: [halfLength, 0, 0], - stopPropagation: () => event.stopPropagation(), - nativeEvent: event.nativeEvent as any, - } as any) - emitter.emit('camera-controls:focus', { nodeId: wall.id }) - }, - [], - ) - const handleFenceClick = useCallback( - (fence: FenceNode, event: ReactMouseEvent) => { - const centerX = (fence.start[0] + fence.end[0]) / 2 - const centerZ = (fence.start[1] + fence.end[1]) / 2 - const halfLength = - Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) / 2 - - setSelectedReferenceId(null) - emitter.emit('fence:click', { - node: fence, - position: [centerX, 0, centerZ], - localPosition: [halfLength, 0, 0], - stopPropagation: () => event.stopPropagation(), - nativeEvent: event.nativeEvent as any, - } as any) - }, - [setSelectedReferenceId], - ) - const handleFenceDoubleClick = useCallback( - (fence: FenceNode, event: ReactMouseEvent) => { - const centerX = (fence.start[0] + fence.end[0]) / 2 - const centerZ = (fence.start[1] + fence.end[1]) / 2 - const halfLength = - Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) / 2 - - emitter.emit('fence:double-click', { - node: fence, - position: [centerX, 0, centerZ], - localPosition: [halfLength, 0, 0], - stopPropagation: () => event.stopPropagation(), - nativeEvent: event.nativeEvent as any, - } as any) - emitter.emit('camera-controls:focus', { nodeId: fence.id }) - }, - [], - ) - const emitFloorplanNodeClick = useCallback( - ( - nodeId: - | ItemNode['id'] - | OpeningNode['id'] - | SlabNode['id'] - | CeilingNode['id'] - | ElevatorNode['id'] - | SpawnNode['id'] - | StairNode['id'] - | ZoneNodeType['id'], - eventType: 'click' | 'double-click', - event: ReactMouseEvent, - ) => { - const node = useScene.getState().nodes[nodeId as AnyNodeId] - if ( - !( - node && - (node.type === 'slab' || - node.type === 'ceiling' || - node.type === 'door' || - node.type === 'window' || - node.type === 'elevator' || - node.type === 'item' || - node.type === 'spawn' || - node.type === 'stair' || - node.type === 'zone') - ) - ) { - return - } - - setSelectedReferenceId(null) - emitter.emit( - `${node.type}:${eventType}` as any, - { - localPosition: [0, 0, 0], - nativeEvent: event.nativeEvent as any, - node, - position: [0, 0, 0], - stopPropagation: () => event.stopPropagation(), - } as any, - ) - }, - [setSelectedReferenceId], - ) const handleGuideSelect = useCallback( (guideId: GuideNode['id']) => { setSelectedReferenceId(guideId) @@ -14648,1760 +7686,6 @@ export function FloorplanPanel() { [canInteractWithGuides, getSvgPointFromClientPoint, guideUi, selectedGuideId], ) - const handleOpeningSelect = useCallback( - (openingId: OpeningNode['id'], event: ReactMouseEvent) => { - emitFloorplanNodeClick(openingId, 'click', event) - }, - [emitFloorplanNodeClick], - ) - const handleOpeningPointerDown = useCallback( - (openingId: OpeningNode['id'], event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - const opening = selectedOpeningEntry?.opening - if (!opening || opening.id !== openingId) { - return - } - - event.preventDefault() - event.stopPropagation() - - // Suppress the click event that follows this pointer interaction so it - // doesn't re-select or interfere with placement. - const suppressClick = (clickEvent: MouseEvent) => { - clickEvent.stopImmediatePropagation() - clickEvent.preventDefault() - window.removeEventListener('click', suppressClick, true) - } - window.addEventListener('click', suppressClick, true) - requestAnimationFrame(() => { - window.removeEventListener('click', suppressClick, true) - }) - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(opening) - setSelection({ selectedIds: [] }) - }, - [selectedOpeningEntry, setMovingNode, setSelection], - ) - const handleSlabSelect = useCallback( - (slabId: SlabNode['id'], event: ReactMouseEvent) => { - emitFloorplanNodeClick(slabId, 'click', event) - }, - [emitFloorplanNodeClick], - ) - const handleCeilingSelect = useCallback( - (ceilingId: CeilingNode['id'], event: ReactMouseEvent) => { - emitFloorplanNodeClick(ceilingId, 'click', event) - }, - [emitFloorplanNodeClick], - ) - const handleZoneSelect = useCallback( - (zoneId: ZoneNodeType['id'], event: ReactMouseEvent) => { - emitFloorplanNodeClick(zoneId, 'click', event) - }, - [emitFloorplanNodeClick], - ) - const handleItemSelect = useCallback( - (itemId: ItemNode['id'], event: ReactMouseEvent) => { - emitFloorplanNodeClick(itemId, 'click', event) - }, - [emitFloorplanNodeClick], - ) - const handleSpawnSelect = useCallback( - (spawnId: SpawnNode['id'], event: ReactMouseEvent) => { - emitFloorplanNodeClick(spawnId, 'click', event) - }, - [emitFloorplanNodeClick], - ) - const handleStairSelect = useCallback( - (stairId: StairNode['id'], event: ReactMouseEvent) => { - emitFloorplanNodeClick(stairId, 'click', event) - }, - [emitFloorplanNodeClick], - ) - const handleElevatorSelect = useCallback( - (elevator: ElevatorNode, event: ReactMouseEvent) => { - emitFloorplanNodeClick(elevator.id, 'click', event) - }, - [emitFloorplanNodeClick], - ) - const handleElevatorPointerDown = useCallback( - (elevatorId: ElevatorNode['id'], event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - const elevator = selectedElevatorEntry?.elevator - if (!elevator || elevator.id !== elevatorId) { - return - } - - event.preventDefault() - event.stopPropagation() - - const suppressClick = (clickEvent: MouseEvent) => { - clickEvent.stopImmediatePropagation() - clickEvent.preventDefault() - window.removeEventListener('click', suppressClick, true) - } - window.addEventListener('click', suppressClick, true) - requestAnimationFrame(() => { - window.removeEventListener('click', suppressClick, true) - }) - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(elevator) - setSelection({ selectedIds: [] }) - }, - [selectedElevatorEntry, setMovingNode, setSelection], - ) - const handleZoneLabelClick = useCallback( - (zoneId: ZoneNodeType['id'], _event: ReactMouseEvent) => { - const currentZoneId = useViewer.getState().selection.zoneId - if (currentZoneId === zoneId) { - // Already selected → enter text editing (second click) - emitter.emit('zone:edit-label' as any, { zoneId }) - return - } - // Not selected → select zone + switch to zone mode - useEditor.getState().setPhase('structure') - useEditor.getState().setStructureLayer('zones') - useEditor.getState().setMode('select') - setSelection({ zoneId }) - }, - [setSelection], - ) - const handleSlabDoubleClick = useCallback((slab: SlabNode) => { - emitter.emit('camera-controls:focus', { nodeId: slab.id }) - }, []) - const handleCeilingDoubleClick = useCallback((ceiling: CeilingNode) => { - emitter.emit('camera-controls:focus', { nodeId: ceiling.id }) - }, []) - const handleOpeningDoubleClick = useCallback((opening: OpeningNode) => { - emitter.emit('camera-controls:focus', { nodeId: opening.id }) - }, []) - const handleItemDoubleClick = useCallback( - (item: ItemNode, event: ReactMouseEvent) => { - emitFloorplanNodeClick(item.id, 'double-click', event) - emitter.emit('camera-controls:focus', { nodeId: item.id }) - }, - [emitFloorplanNodeClick], - ) - const handleSpawnDoubleClick = useCallback( - (spawn: SpawnNode, event: ReactMouseEvent) => { - emitFloorplanNodeClick(spawn.id, 'double-click', event) - emitter.emit('camera-controls:focus', { nodeId: spawn.id }) - }, - [emitFloorplanNodeClick], - ) - const handleSpawnPointerDown = useCallback( - (spawnId: SpawnNode['id'], event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - const spawn = selectedSpawnEntry?.spawn - if (!spawn || spawn.id !== spawnId) { - return - } - - event.preventDefault() - event.stopPropagation() - - const suppressClick = (clickEvent: MouseEvent) => { - clickEvent.stopImmediatePropagation() - clickEvent.preventDefault() - window.removeEventListener('click', suppressClick, true) - } - window.addEventListener('click', suppressClick, true) - requestAnimationFrame(() => { - window.removeEventListener('click', suppressClick, true) - }) - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(spawn) - setSelection({ selectedIds: [] }) - }, - [selectedSpawnEntry, setMovingNode, setSelection], - ) - const handleSelectedSpawnMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const spawn = selectedSpawnEntry?.spawn - if (!spawn) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(spawn) - setSelection({ selectedIds: [] }) - }, - [selectedSpawnEntry, setMovingNode, setSelection], - ) - const handleSelectedSpawnDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const spawn = selectedSpawnEntry?.spawn - if (!spawn) { - return - } - - sfxEmitter.emit('sfx:item-delete') - deleteNode(spawn.id as AnyNodeId) - setSelection({ selectedIds: [] }) - }, - [deleteNode, selectedSpawnEntry, setSelection], - ) - const handleSelectedElevatorMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const elevator = selectedElevatorEntry?.elevator - if (!elevator) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(elevator) - setSelection({ selectedIds: [] }) - }, - [selectedElevatorEntry, setMovingNode, setSelection], - ) - const handleSelectedElevatorDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const elevator = selectedElevatorEntry?.elevator - if (!elevator) { - return - } - - sfxEmitter.emit('sfx:item-delete') - deleteNode(elevator.id as AnyNodeId) - setSelection({ selectedIds: [] }) - }, - [deleteNode, selectedElevatorEntry, setSelection], - ) - const handleItemPointerDown = useCallback( - (itemId: ItemNode['id'], event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - const item = selectedItemEntry?.item - if (!item || item.id !== itemId) { - return - } - - event.preventDefault() - event.stopPropagation() - - // Suppress the click event that follows this pointer interaction so it - // doesn't re-select or interfere with placement. - const suppressClick = (clickEvent: MouseEvent) => { - clickEvent.stopImmediatePropagation() - clickEvent.preventDefault() - window.removeEventListener('click', suppressClick, true) - } - window.addEventListener('click', suppressClick, true) - requestAnimationFrame(() => { - window.removeEventListener('click', suppressClick, true) - }) - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(item) - setSelection({ selectedIds: [] }) - }, - [selectedItemEntry, setMovingNode, setSelection], - ) - const handleSelectedItemMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const item = selectedItemEntry?.item - if (!item) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(item) - setSelection({ selectedIds: [] }) - }, - [selectedItemEntry, setMovingNode, setSelection], - ) - const duplicateSelectedItem = useCallback(() => { - const item = selectedItemEntry?.item - if (!item) { - return - } - - sfxEmitter.emit('sfx:item-pick') - - const cloned = structuredClone(item) as Record - delete cloned.id - cloned.metadata = { - ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), - isNew: true, - } - cloned.children = [] - - try { - const duplicate = ItemNodeSchema.parse(cloned) - setMovingNode(duplicate) - setSelection({ selectedIds: [] }) - } catch (error) { - console.error('Failed to duplicate item', error) - } - }, [selectedItemEntry, setMovingNode, setSelection]) - const handleSelectedItemDuplicate = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - duplicateSelectedItem() - }, - [duplicateSelectedItem], - ) - const handleSelectedItemDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const item = selectedItemEntry?.item - if (!item) { - return - } - - sfxEmitter.emit('sfx:item-delete') - deleteNode(item.id as AnyNodeId) - setSelection({ selectedIds: [] }) - }, - [deleteNode, selectedItemEntry, setSelection], - ) - const handleSelectedWallMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const wall = selectedWallEntry?.wall - if (!wall) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(wall) - setSelection({ selectedIds: [] }) - }, - [selectedWallEntry, setMovingNode, setSelection], - ) - const duplicateSelectedWall = useCallback(() => { - const wall = selectedWallEntry?.wall - if (!wall?.parentId) { - return - } - - sfxEmitter.emit('sfx:item-pick') - - const cloned = structuredClone(wall) as Record - delete cloned.id - cloned.children = [] - cloned.metadata = { - ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), - isNew: true, - } - - const temporal = useScene.temporal.getState() - temporal.pause() - try { - const duplicate = WallNodeSchema.parse(cloned) - useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) - setMovingNode(duplicate) - setSelection({ selectedIds: [] }) - } catch (error) { - console.error('Failed to duplicate wall', error) - } finally { - temporal.resume() - } - }, [selectedWallEntry, setMovingNode, setSelection]) - const handleSelectedWallDuplicate = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - duplicateSelectedWall() - }, - [duplicateSelectedWall], - ) - const handleSelectedWallCurve = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const wall = selectedWallEntry?.wall - if (!(wall && canCurveSelectedWall)) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setCurvingWall(wall) - setSelection({ selectedIds: [] }) - }, - [canCurveSelectedWall, selectedWallEntry, setCurvingWall, setSelection], - ) - const handleSelectedWallDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const wall = selectedWallEntry?.wall - if (!wall) { - return - } - - sfxEmitter.emit('sfx:item-delete') - deleteNode(wall.id as AnyNodeId) - setSelection({ selectedIds: [] }) - }, - [deleteNode, selectedWallEntry, setSelection], - ) - const handleSelectedSlabMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const slab = selectedSlabEntry?.slab - if (!slab) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(slab) - setSelection({ selectedIds: [] }) - }, - [selectedSlabEntry, setMovingNode, setSelection], - ) - const handleSelectedSlabAddHole = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const slab = selectedSlabEntry?.slab - if (!(slab && slab.polygon.length > 0)) { - return - } - - const [sumX, sumZ] = slab.polygon.reduce( - ([currentX, currentZ], [x, z]) => [currentX + x, currentZ + z], - [0, 0], - ) - const cx = sumX / slab.polygon.length - const cz = sumZ / slab.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 = slab.holes ?? [] - const currentMetadata = currentHoles.map( - (_, index) => slab.holeMetadata?.[index] ?? { source: 'manual' as const }, - ) - - updateNode(slab.id, { - holes: [...currentHoles, newHole], - holeMetadata: [...currentMetadata, { source: 'manual' }], - }) - setEditingHole({ nodeId: slab.id, holeIndex: currentHoles.length }) - sfxEmitter.emit('sfx:structure-build') - }, - [selectedSlabEntry, setEditingHole, updateNode], - ) - const handleSelectedSlabHoleMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const slab = selectedSlabEntry?.slab - const holeIndex = selectedSlabEditingHoleIndex - const hole = selectedSlabEditingHole - if (!(slab && holeIndex !== null && hole && hole.length > 0)) { - return - } - - const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) - const [sumX, sumY] = hole.reduce( - ([currentX, currentY], point) => [currentX + point.x, currentY + point.y], - [0, 0], - ) - const startPlanPoint = - planPoint ?? ([sumX / hole.length, sumY / hole.length] as WallPlanPoint) - const originalPolygon = hole.map(toWallPlanPoint) - - setSlabHoleBoundaryDraft(null) - setSlabHoleVertexDragState(null) - setSlabHoleMoveDraft({ - slabId: slab.id, - holeIndex, - polygon: originalPolygon, - originalPolygon, - startPlanPoint, - }) - setCursorPoint(startPlanPoint) - sfxEmitter.emit('sfx:item-pick') - }, - [ - getPlanPointFromClientPoint, - selectedSlabEditingHole, - selectedSlabEditingHoleIndex, - selectedSlabEntry, - ], - ) - const handleSelectedSlabHoleDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const slab = selectedSlabEntry?.slab - const holeIndex = selectedSlabEditingHoleIndex - if (!(slab && holeIndex !== null)) { - return - } - - const currentHoles = slab.holes ?? [] - if ( - !currentHoles[holeIndex] || - (slab.holeMetadata?.[holeIndex]?.source ?? 'manual') !== 'manual' - ) { - return - } - - const currentMetadata = currentHoles.map( - (_, index) => slab.holeMetadata?.[index] ?? { source: 'manual' as const }, - ) - updateNode(slab.id, { - holes: currentHoles.filter((_, index) => index !== holeIndex), - holeMetadata: currentMetadata.filter((_, index) => index !== holeIndex), - }) - setEditingHole(null) - setSlabHoleBoundaryDraft(null) - setSlabHoleMoveDraft(null) - setSlabHoleVertexDragState(null) - sfxEmitter.emit('sfx:item-delete') - }, - [selectedSlabEditingHoleIndex, selectedSlabEntry, setEditingHole, updateNode], - ) - const handleSelectedSlabDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const slab = selectedSlabEntry?.slab - if (!slab) { - return - } - - sfxEmitter.emit('sfx:item-delete') - deleteNode(slab.id as AnyNodeId) - setSelection({ selectedIds: [] }) - }, - [deleteNode, selectedSlabEntry, setSelection], - ) - const handleSelectedCeilingMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const ceiling = selectedCeilingEntry?.ceiling - if (!ceiling) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(ceiling) - setSelection({ selectedIds: [] }) - }, - [selectedCeilingEntry, setMovingNode, setSelection], - ) - const handleSelectedCeilingAddHole = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const ceiling = selectedCeilingEntry?.ceiling - if (!(ceiling && ceiling.polygon.length > 0)) { - return - } - - const [sumX, sumZ] = ceiling.polygon.reduce( - ([currentX, currentZ], [x, z]) => [currentX + x, currentZ + z], - [0, 0], - ) - const cx = sumX / ceiling.polygon.length - const cz = sumZ / ceiling.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 = ceiling.holes ?? [] - const currentMetadata = currentHoles.map( - (_, index) => ceiling.holeMetadata?.[index] ?? { source: 'manual' as const }, - ) - - updateNode(ceiling.id, { - holes: [...currentHoles, newHole], - holeMetadata: [...currentMetadata, { source: 'manual' }], - }) - setEditingHole({ nodeId: ceiling.id, holeIndex: currentHoles.length }) - sfxEmitter.emit('sfx:structure-build') - }, - [selectedCeilingEntry, setEditingHole, updateNode], - ) - const handleSelectedCeilingHoleMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const ceiling = selectedCeilingEntry?.ceiling - const holeIndex = selectedCeilingEditingHoleIndex - const hole = selectedCeilingEditingHole - if (!(ceiling && holeIndex !== null && hole && hole.length > 0)) { - return - } - - const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) - const [sumX, sumY] = hole.reduce( - ([currentX, currentY], point) => [currentX + point.x, currentY + point.y], - [0, 0], - ) - const startPlanPoint = - planPoint ?? ([sumX / hole.length, sumY / hole.length] as WallPlanPoint) - const originalPolygon = hole.map(toWallPlanPoint) - - setCeilingHoleBoundaryDraft(null) - setCeilingHoleVertexDragState(null) - setCeilingHoleMoveDraft({ - ceilingId: ceiling.id, - holeIndex, - polygon: originalPolygon, - originalPolygon, - startPlanPoint, - }) - setCursorPoint(startPlanPoint) - sfxEmitter.emit('sfx:item-pick') - }, - [ - getPlanPointFromClientPoint, - selectedCeilingEditingHole, - selectedCeilingEditingHoleIndex, - selectedCeilingEntry, - ], - ) - const handleSelectedCeilingHoleDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const ceiling = selectedCeilingEntry?.ceiling - const holeIndex = selectedCeilingEditingHoleIndex - if (!(ceiling && holeIndex !== null)) { - return - } - - const currentHoles = ceiling.holes ?? [] - if ( - !currentHoles[holeIndex] || - (ceiling.holeMetadata?.[holeIndex]?.source ?? 'manual') !== 'manual' - ) { - return - } - - const currentMetadata = currentHoles.map( - (_, index) => ceiling.holeMetadata?.[index] ?? { source: 'manual' as const }, - ) - updateNode(ceiling.id, { - holes: currentHoles.filter((_, index) => index !== holeIndex), - holeMetadata: currentMetadata.filter((_, index) => index !== holeIndex), - }) - setEditingHole(null) - setCeilingHoleBoundaryDraft(null) - setCeilingHoleMoveDraft(null) - setCeilingHoleVertexDragState(null) - sfxEmitter.emit('sfx:item-delete') - }, - [selectedCeilingEditingHoleIndex, selectedCeilingEntry, setEditingHole, updateNode], - ) - const handleSelectedCeilingDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const ceiling = selectedCeilingEntry?.ceiling - if (!ceiling) { - return - } - - sfxEmitter.emit('sfx:item-delete') - deleteNode(ceiling.id as AnyNodeId) - setSelection({ selectedIds: [] }) - }, - [deleteNode, selectedCeilingEntry, setSelection], - ) - const handleSelectedFenceMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const fence = selectedFenceEntry?.fence - if (!fence) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(fence) - setSelection({ selectedIds: [] }) - }, - [selectedFenceEntry, setMovingNode, setSelection], - ) - const handleSelectedFenceDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const fence = selectedFenceEntry?.fence - if (!fence) { - return - } - - sfxEmitter.emit('sfx:item-delete') - deleteNode(fence.id as AnyNodeId) - setSelection({ selectedIds: [] }) - }, - [deleteNode, selectedFenceEntry, setSelection], - ) - const handleFencePointerDown = useCallback( - (fenceId: FenceNode['id'], event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - const fence = selectedFenceEntry?.fence - if (!fence || fence.id !== fenceId) { - return - } - - event.preventDefault() - event.stopPropagation() - - pendingFenceDragRef.current = { - pointerId: event.pointerId, - fenceId, - startClientX: event.clientX, - startClientY: event.clientY, - } - }, - [selectedFenceEntry], - ) - const handleFenceEndpointPointerDown = useCallback( - (fence: FenceNode, endpoint: WallEndpoint, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - pendingFenceDragRef.current = null - setHoveredEndpointId(null) - - if (mode !== 'select') { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingFenceEndpoint({ fence, endpoint }) - }, - [mode, setMovingFenceEndpoint], - ) - const handleStairDoubleClick = useCallback( - (stair: StairNode, event: ReactMouseEvent) => { - emitFloorplanNodeClick(stair.id, 'double-click', event) - emitter.emit('camera-controls:focus', { nodeId: stair.id }) - }, - [emitFloorplanNodeClick], - ) - const handleStairPointerDown = useCallback( - (stairId: StairNode['id'], event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - const stair = selectedStairEntry?.stair - if (!stair || stair.id !== stairId) { - return - } - - event.preventDefault() - event.stopPropagation() - - const suppressClick = (clickEvent: MouseEvent) => { - clickEvent.stopImmediatePropagation() - clickEvent.preventDefault() - window.removeEventListener('click', suppressClick, true) - } - window.addEventListener('click', suppressClick, true) - requestAnimationFrame(() => { - window.removeEventListener('click', suppressClick, true) - }) - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(stair) - setSelection({ selectedIds: [] }) - }, - [selectedStairEntry, setMovingNode, setSelection], - ) - const handleSelectedOpeningMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const opening = selectedOpeningEntry?.opening - if (!opening) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(opening) - setSelection({ selectedIds: [] }) - }, - [selectedOpeningEntry, setMovingNode, setSelection], - ) - const duplicateSelectedOpening = useCallback(() => { - const opening = selectedOpeningEntry?.opening - if (!opening?.parentId) { - return - } - - sfxEmitter.emit('sfx:item-pick') - useScene.temporal.getState().pause() - - const cloned = structuredClone(opening) as Record - delete cloned.id - cloned.metadata = { - ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), - isNew: true, - } - - const duplicate = opening.type === 'door' ? DoorNode.parse(cloned) : WindowNode.parse(cloned) - - useScene.getState().createNode(duplicate, opening.parentId as AnyNodeId) - setMovingNode(duplicate) - setSelection({ selectedIds: [] }) - }, [selectedOpeningEntry, setMovingNode, setSelection]) - const handleSelectedOpeningDuplicate = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - duplicateSelectedOpening() - }, - [duplicateSelectedOpening], - ) - const handleSelectedOpeningDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const opening = selectedOpeningEntry?.opening - if (!opening) { - return - } - - sfxEmitter.emit('sfx:item-delete') - deleteNode(opening.id as AnyNodeId) - if (opening.parentId) { - useScene.getState().dirtyNodes.add(opening.parentId as AnyNodeId) - } - setSelection({ selectedIds: [] }) - }, - [deleteNode, selectedOpeningEntry, setSelection], - ) - const handleSelectedStairMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const stair = selectedStairEntry?.stair - if (!stair) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(stair) - setSelection({ selectedIds: [] }) - }, - [selectedStairEntry, setMovingNode, setSelection], - ) - const duplicateSelectedStair = useCallback(() => { - const stair = selectedStairEntry?.stair - if (!stair) { - return - } - - sfxEmitter.emit('sfx:item-pick') - useScene.temporal.getState().pause() - - try { - duplicateStairSubtree(stair.id as AnyNodeId, { mode: 'move' }) - } catch (error) { - console.error('Failed to duplicate stair', error) - } - }, [selectedStairEntry, setSelection]) - const handleSelectedStairDuplicate = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - duplicateSelectedStair() - }, - [duplicateSelectedStair], - ) - const handleSelectedStairDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const stair = selectedStairEntry?.stair - if (!stair) { - return - } - - sfxEmitter.emit('sfx:item-delete') - deleteNode(stair.id as AnyNodeId) - if (stair.parentId) { - useScene.getState().dirtyNodes.add(stair.parentId as AnyNodeId) - } - setSelection({ selectedIds: [] }) - }, - [deleteNode, selectedStairEntry, setSelection], - ) - const handleSelectedRoofMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const roof = selectedRoofEntry?.roof - if (!roof) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(roof) - setSelection({ selectedIds: [] }) - }, - [selectedRoofEntry, setMovingNode, setSelection], - ) - const duplicateSelectedRoof = useCallback(() => { - const roof = selectedRoofEntry?.roof - if (!roof) { - return - } - - sfxEmitter.emit('sfx:item-pick') - - try { - duplicateRoofSubtree(roof.id as AnyNodeId, { mode: 'move' }) - } catch (error) { - console.error('Failed to duplicate roof', error) - } - }, [selectedRoofEntry]) - const handleSelectedRoofDuplicate = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - duplicateSelectedRoof() - }, - [duplicateSelectedRoof], - ) - const handleSelectedRoofDelete = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const roof = selectedRoofEntry?.roof - if (!roof) { - return - } - - sfxEmitter.emit('sfx:item-delete') - deleteNode(roof.id as AnyNodeId) - setSelection({ selectedIds: [] }) - }, - [deleteNode, selectedRoofEntry, setSelection], - ) - - const handleWallEndpointPointerDown = useCallback( - (wall: WallNode, endpoint: WallEndpoint, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredEndpointId(null) - - const movingPoint = endpoint === 'start' ? wall.start : wall.end - - if (isWallBuildActive) { - handleWallPlacementPoint(movingPoint) - return - } - - if (mode !== 'select') { - return - } - - clearWallPlacementDraft() - handleWallSelect(wall) - - const fixedPoint = endpoint === 'start' ? wall.end : wall.start - const originalStart = [...wall.start] as WallPlanPoint - const originalEnd = [...wall.end] as WallPlanPoint - const linkedWalls = getLinkedWallSnapshots(walls, wall.id, originalStart, originalEnd) - - wallEndpointDragRef.current = { - pointerId: event.pointerId, - wallId: wall.id, - endpoint, - fixedPoint, - currentPoint: movingPoint, - originalStart, - originalEnd, - linkedWalls, - } - - setWallEndpointDraft( - buildWallEndpointDraft(wall.id, endpoint, fixedPoint, movingPoint, linkedWalls), - ) - setCursorPoint(movingPoint) - }, - [ - clearWallPlacementDraft, - handleWallPlacementPoint, - handleWallSelect, - isWallBuildActive, - mode, - walls, - ], - ) - const handleWallCurvePointerDown = useCallback( - (wall: WallNode, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredWallCurveHandleId(null) - - if (isWallBuildActive || mode !== 'select') { - return - } - - clearWallPlacementDraft() - handleWallSelect(wall) - clearWallEndpointDrag() - - const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0) - wallCurveDragRef.current = { - pointerId: event.pointerId, - wallId: wall.id, - currentCurveOffset, - } - setWallCurveDraft({ - wallId: wall.id, - curveOffset: currentCurveOffset, - }) - const center = getWallMidpointHandlePoint(wall) - setCursorPoint([center.x, center.y]) - }, - [clearWallEndpointDrag, clearWallPlacementDraft, handleWallSelect, isWallBuildActive, mode], - ) - const handleSlabVertexPointerDown = useCallback( - (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredSlabHandleId(null) - - const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) - const vertexPoint = slabEntry?.polygon[vertexIndex] - const handlePolygon = slabEntry ? getSlabHandlePolygon(slabEntry) : [] - const handlePoint = - vertexPoint && handlePolygon.length > 0 - ? handlePolygon[getClosestPolygonVertexIndex(vertexPoint, handlePolygon)] - : null - if (!(slabEntry && vertexPoint && handlePoint)) { - return - } - - const visualOffsets = getSlabVisualOffsets(slabEntry) - - setSlabBoundaryDraft({ - slabId, - polygon: slabEntry.polygon.map(toWallPlanPoint), - visualOffsets, - }) - setSlabVertexDragState({ - pointerId: event.pointerId, - slabId, - vertexIndex, - visualOffset: { - x: handlePoint.x - vertexPoint.x, - y: handlePoint.y - vertexPoint.y, - }, - }) - setCursorPoint(toWallPlanPoint(handlePoint)) - }, - [displaySlabPolygons], - ) - const handleSlabVertexDoubleClick = useCallback( - (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - - const slab = slabById.get(slabId) - if (!(slab && slab.polygon.length > 3)) { - return - } - - slabBoundaryDraftRef.current = null - clearSlabBoundaryInteraction() - - updateNode(slabId, { - polygon: slab.polygon.filter((_, index) => index !== vertexIndex), - }) - }, - [clearSlabBoundaryInteraction, slabById, updateNode], - ) - const handleSlabMidpointPointerDown = useCallback( - ( - slabId: SlabNode['id'], - handleEdgeIndex: number, - event: ReactPointerEvent, - ) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredSlabHandleId(null) - - const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) - if (!slabEntry) { - return - } - - const basePolygon = slabEntry.polygon.map(toWallPlanPoint) - const handlePolygon = getSlabHandlePolygon(slabEntry) - const handleStartPoint = handlePolygon[handleEdgeIndex] - const handleEndPoint = handlePolygon[(handleEdgeIndex + 1) % handlePolygon.length] - const insertedHandlePoint: WallPlanPoint = - handleStartPoint && handleEndPoint - ? [ - (handleStartPoint.x + handleEndPoint.x) / 2, - (handleStartPoint.y + handleEndPoint.y) / 2, - ] - : (basePolygon[handleEdgeIndex] ?? basePolygon[0] ?? ([0, 0] as WallPlanPoint)) - const edgeIndex = getClosestPolygonEdgeIndex( - toPoint2D(insertedHandlePoint), - slabEntry.polygon, - ) - const startPoint = basePolygon[edgeIndex] - const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] - if (!(startPoint && endPoint)) { - return - } - const insertedPoint: WallPlanPoint = [ - (startPoint[0] + endPoint[0]) / 2, - (startPoint[1] + endPoint[1]) / 2, - ] - const insertIndex = edgeIndex + 1 - const nextPolygon = [ - ...basePolygon.slice(0, insertIndex), - insertedPoint, - ...basePolygon.slice(insertIndex), - ] - const visualOffsets = getSlabVisualOffsets(slabEntry) - const insertedVisualOffset = { - x: insertedHandlePoint[0] - insertedPoint[0], - y: insertedHandlePoint[1] - insertedPoint[1], - } - const nextVisualOffsets = [ - ...visualOffsets.slice(0, insertIndex), - insertedVisualOffset, - ...visualOffsets.slice(insertIndex), - ] - - setSlabBoundaryDraft({ - slabId, - polygon: nextPolygon, - visualOffsets: nextVisualOffsets, - }) - setSlabVertexDragState({ - pointerId: event.pointerId, - slabId, - vertexIndex: insertIndex, - visualOffset: insertedVisualOffset, - }) - setCursorPoint(insertedHandlePoint) - }, - [displaySlabPolygons], - ) - const handleSlabEdgePointerDown = useCallback( - (slabId: SlabNode['id'], handleEdgeIndex: number, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredSlabHandleId(null) - - const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) - if (!slabEntry) { - return - } - - const basePolygon = slabEntry.polygon.map(toWallPlanPoint) - const handlePolygon = getSlabHandlePolygon(slabEntry) - const handleStartPoint = handlePolygon[handleEdgeIndex] - const handleEndPoint = handlePolygon[(handleEdgeIndex + 1) % handlePolygon.length] - if (!(handleStartPoint && handleEndPoint)) { - return - } - - const handleMidpoint = { - x: (handleStartPoint.x + handleEndPoint.x) / 2, - y: (handleStartPoint.y + handleEndPoint.y) / 2, - } - const edgeIndex = getClosestPolygonEdgeIndex(handleMidpoint, slabEntry.polygon) - const startPoint = basePolygon[edgeIndex] - const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] - if (!(startPoint && endPoint)) { - return - } - - const edgeNormal = getFloorplanEdgeNormal(startPoint, endPoint) - if (!edgeNormal) { - return - } - - const initialPlanPoint = - getPlanPointFromClientPoint(event.clientX, event.clientY) ?? - ([(startPoint[0] + endPoint[0]) / 2, (startPoint[1] + endPoint[1]) / 2] as WallPlanPoint) - - setSlabBoundaryDraft({ - slabId, - polygon: basePolygon, - visualOffsets: getSlabVisualOffsets(slabEntry), - }) - setSlabVertexDragState({ - pointerId: event.pointerId, - slabId, - mode: 'edge', - vertexIndex: edgeIndex, - visualOffset: { x: 0, y: 0 }, - edgeIndex, - edgeNormal, - initialPlanPoint, - initialPolygon: basePolygon, - }) - setCursorPoint(initialPlanPoint) - }, - [displaySlabPolygons, getPlanPointFromClientPoint], - ) - const handleCeilingVertexPointerDown = useCallback( - ( - ceilingId: CeilingNode['id'], - vertexIndex: number, - event: ReactPointerEvent, - ) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredCeilingHandleId(null) - - const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) - const vertexPoint = ceilingEntry?.polygon[vertexIndex] - if (!(ceilingEntry && vertexPoint)) { - return - } - - setCeilingBoundaryDraft({ - ceilingId, - polygon: ceilingEntry.polygon.map(toWallPlanPoint), - }) - setCeilingVertexDragState({ - pointerId: event.pointerId, - ceilingId, - vertexIndex, - }) - setCursorPoint(toWallPlanPoint(vertexPoint)) - }, - [displayCeilingPolygons], - ) - const handleCeilingVertexDoubleClick = useCallback( - ( - ceilingId: CeilingNode['id'], - vertexIndex: number, - event: ReactPointerEvent, - ) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - - const ceiling = ceilingById.get(ceilingId) - if (!(ceiling && ceiling.polygon.length > 3)) { - return - } - - ceilingBoundaryDraftRef.current = null - clearCeilingBoundaryInteraction() - - updateNode(ceilingId, { - polygon: ceiling.polygon.filter((_, index) => index !== vertexIndex), - }) - }, - [ceilingById, clearCeilingBoundaryInteraction, updateNode], - ) - const handleCeilingMidpointPointerDown = useCallback( - ( - ceilingId: CeilingNode['id'], - edgeIndex: number, - event: ReactPointerEvent, - ) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredCeilingHandleId(null) - - const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) - if (!ceilingEntry) { - return - } - - const basePolygon = ceilingEntry.polygon.map(toWallPlanPoint) - const startPoint = basePolygon[edgeIndex] - const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] - if (!(startPoint && endPoint)) { - return - } - - const insertedPoint: WallPlanPoint = [ - (startPoint[0] + endPoint[0]) / 2, - (startPoint[1] + endPoint[1]) / 2, - ] - const insertIndex = edgeIndex + 1 - const nextPolygon = [ - ...basePolygon.slice(0, insertIndex), - insertedPoint, - ...basePolygon.slice(insertIndex), - ] - - setCeilingBoundaryDraft({ - ceilingId, - polygon: nextPolygon, - }) - setCeilingVertexDragState({ - pointerId: event.pointerId, - ceilingId, - vertexIndex: insertIndex, - }) - setCursorPoint(insertedPoint) - }, - [displayCeilingPolygons], - ) - const handleCeilingEdgePointerDown = useCallback( - (ceilingId: CeilingNode['id'], edgeIndex: number, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredCeilingHandleId(null) - - const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) - if (!ceilingEntry) { - return - } - - const basePolygon = ceilingEntry.polygon.map(toWallPlanPoint) - const startPoint = basePolygon[edgeIndex] - const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] - if (!(startPoint && endPoint)) { - return - } - - const edgeNormal = getFloorplanEdgeNormal(startPoint, endPoint) - if (!edgeNormal) { - return - } - - const initialPlanPoint = - getPlanPointFromClientPoint(event.clientX, event.clientY) ?? - ([(startPoint[0] + endPoint[0]) / 2, (startPoint[1] + endPoint[1]) / 2] as WallPlanPoint) - - setCeilingBoundaryDraft({ - ceilingId, - polygon: basePolygon, - }) - setCeilingVertexDragState({ - pointerId: event.pointerId, - ceilingId, - mode: 'edge', - vertexIndex: edgeIndex, - edgeIndex, - edgeNormal, - initialPlanPoint, - initialPolygon: basePolygon, - }) - setCursorPoint(initialPlanPoint) - }, - [displayCeilingPolygons, getPlanPointFromClientPoint], - ) - const handleSlabHoleVertexPointerDown = useCallback( - (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredSlabHandleId(null) - - const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) - const holeIndex = editingHole?.nodeId === slabId ? editingHole.holeIndex : null - const hole = holeIndex !== null ? slabEntry?.holes[holeIndex] : null - const vertexPoint = hole?.[vertexIndex] - if (!(slabEntry && holeIndex !== null && hole && vertexPoint)) { - return - } - - setSlabHoleBoundaryDraft({ - slabId, - holeIndex, - polygon: hole.map(toWallPlanPoint), - }) - setSlabHoleVertexDragState({ - pointerId: event.pointerId, - slabId, - holeIndex, - vertexIndex, - }) - setCursorPoint(toWallPlanPoint(vertexPoint)) - }, - [displaySlabPolygons, editingHole], - ) - const handleSlabHoleVertexDoubleClick = useCallback( - (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - - const slab = slabById.get(slabId) - const holeIndex = editingHole?.nodeId === slabId ? editingHole.holeIndex : null - const hole = holeIndex !== null ? slab?.holes?.[holeIndex] : null - if (!(slab && holeIndex !== null && hole && hole.length > 3)) { - return - } - - slabHoleBoundaryDraftRef.current = null - clearSlabHoleBoundaryInteraction() - - const nextHoles = [...(slab.holes ?? [])] - nextHoles[holeIndex] = hole.filter((_, index) => index !== vertexIndex) - updateNode(slabId, { - holes: nextHoles, - }) - }, - [clearSlabHoleBoundaryInteraction, editingHole, slabById, updateNode], - ) - const handleSlabHoleMidpointPointerDown = useCallback( - (slabId: SlabNode['id'], edgeIndex: number, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredSlabHandleId(null) - - const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) - const holeIndex = editingHole?.nodeId === slabId ? editingHole.holeIndex : null - const hole = holeIndex !== null ? slabEntry?.holes[holeIndex] : null - if (!(slabEntry && holeIndex !== null && hole)) { - return - } - - const basePolygon = hole.map(toWallPlanPoint) - const startPoint = basePolygon[edgeIndex] - const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] - if (!(startPoint && endPoint)) { - return - } - - const insertedPoint: WallPlanPoint = [ - (startPoint[0] + endPoint[0]) / 2, - (startPoint[1] + endPoint[1]) / 2, - ] - const insertIndex = edgeIndex + 1 - const nextPolygon = [ - ...basePolygon.slice(0, insertIndex), - insertedPoint, - ...basePolygon.slice(insertIndex), - ] - - setSlabHoleBoundaryDraft({ - slabId, - holeIndex, - polygon: nextPolygon, - }) - setSlabHoleVertexDragState({ - pointerId: event.pointerId, - slabId, - holeIndex, - vertexIndex: insertIndex, - }) - setCursorPoint(insertedPoint) - }, - [displaySlabPolygons, editingHole], - ) - const handleSlabHoleEdgePointerDown = useCallback( - (slabId: SlabNode['id'], edgeIndex: number, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredSlabHandleId(null) - - const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) - const holeIndex = editingHole?.nodeId === slabId ? editingHole.holeIndex : null - const hole = holeIndex !== null ? slabEntry?.holes[holeIndex] : null - if (!(slabEntry && holeIndex !== null && hole)) { - return - } - - const basePolygon = hole.map(toWallPlanPoint) - const startPoint = basePolygon[edgeIndex] - const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] - if (!(startPoint && endPoint)) { - return - } - - const edgeNormal = getFloorplanEdgeNormal(startPoint, endPoint) - if (!edgeNormal) { - return - } - - const initialPlanPoint = - getPlanPointFromClientPoint(event.clientX, event.clientY) ?? - ([(startPoint[0] + endPoint[0]) / 2, (startPoint[1] + endPoint[1]) / 2] as WallPlanPoint) - - setSlabHoleBoundaryDraft({ - slabId, - holeIndex, - polygon: basePolygon, - }) - setSlabHoleVertexDragState({ - pointerId: event.pointerId, - slabId, - holeIndex, - mode: 'edge', - vertexIndex: edgeIndex, - edgeIndex, - edgeNormal, - initialPlanPoint, - initialPolygon: basePolygon, - }) - setCursorPoint(initialPlanPoint) - }, - [displaySlabPolygons, editingHole, getPlanPointFromClientPoint], - ) - const handleCeilingHoleVertexPointerDown = useCallback( - ( - ceilingId: CeilingNode['id'], - vertexIndex: number, - event: ReactPointerEvent, - ) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredCeilingHandleId(null) - - const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) - const holeIndex = editingHole?.nodeId === ceilingId ? editingHole.holeIndex : null - const hole = holeIndex !== null ? ceilingEntry?.holes[holeIndex] : null - const vertexPoint = hole?.[vertexIndex] - if (!(ceilingEntry && holeIndex !== null && hole && vertexPoint)) { - return - } - - setCeilingHoleBoundaryDraft({ - ceilingId, - holeIndex, - polygon: hole.map(toWallPlanPoint), - }) - setCeilingHoleVertexDragState({ - pointerId: event.pointerId, - ceilingId, - holeIndex, - vertexIndex, - }) - setCursorPoint(toWallPlanPoint(vertexPoint)) - }, - [displayCeilingPolygons, editingHole], - ) - const handleCeilingHoleVertexDoubleClick = useCallback( - ( - ceilingId: CeilingNode['id'], - vertexIndex: number, - event: ReactPointerEvent, - ) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - - const ceiling = ceilingById.get(ceilingId) - const holeIndex = editingHole?.nodeId === ceilingId ? editingHole.holeIndex : null - const hole = holeIndex !== null ? ceiling?.holes?.[holeIndex] : null - if (!(ceiling && holeIndex !== null && hole && hole.length > 3)) { - return - } - - ceilingHoleBoundaryDraftRef.current = null - clearCeilingHoleBoundaryInteraction() - - const nextHoles = [...(ceiling.holes ?? [])] - nextHoles[holeIndex] = hole.filter((_, index) => index !== vertexIndex) - updateNode(ceilingId, { - holes: nextHoles, - }) - }, - [ceilingById, clearCeilingHoleBoundaryInteraction, editingHole, updateNode], - ) - const handleCeilingHoleMidpointPointerDown = useCallback( - ( - ceilingId: CeilingNode['id'], - edgeIndex: number, - event: ReactPointerEvent, - ) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredCeilingHandleId(null) - - const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) - const holeIndex = editingHole?.nodeId === ceilingId ? editingHole.holeIndex : null - const hole = holeIndex !== null ? ceilingEntry?.holes[holeIndex] : null - if (!(ceilingEntry && holeIndex !== null && hole)) { - return - } - - const basePolygon = hole.map(toWallPlanPoint) - const startPoint = basePolygon[edgeIndex] - const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] - if (!(startPoint && endPoint)) { - return - } - - const insertedPoint: WallPlanPoint = [ - (startPoint[0] + endPoint[0]) / 2, - (startPoint[1] + endPoint[1]) / 2, - ] - const insertIndex = edgeIndex + 1 - const nextPolygon = [ - ...basePolygon.slice(0, insertIndex), - insertedPoint, - ...basePolygon.slice(insertIndex), - ] - - setCeilingHoleBoundaryDraft({ - ceilingId, - holeIndex, - polygon: nextPolygon, - }) - setCeilingHoleVertexDragState({ - pointerId: event.pointerId, - ceilingId, - holeIndex, - vertexIndex: insertIndex, - }) - setCursorPoint(insertedPoint) - }, - [displayCeilingPolygons, editingHole], - ) - const handleCeilingHoleEdgePointerDown = useCallback( - (ceilingId: CeilingNode['id'], edgeIndex: number, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredCeilingHandleId(null) - - const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) - const holeIndex = editingHole?.nodeId === ceilingId ? editingHole.holeIndex : null - const hole = holeIndex !== null ? ceilingEntry?.holes[holeIndex] : null - if (!(ceilingEntry && holeIndex !== null && hole)) { - return - } - - const basePolygon = hole.map(toWallPlanPoint) - const startPoint = basePolygon[edgeIndex] - const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] - if (!(startPoint && endPoint)) { - return - } - - const edgeNormal = getFloorplanEdgeNormal(startPoint, endPoint) - if (!edgeNormal) { - return - } - - const initialPlanPoint = - getPlanPointFromClientPoint(event.clientX, event.clientY) ?? - ([(startPoint[0] + endPoint[0]) / 2, (startPoint[1] + endPoint[1]) / 2] as WallPlanPoint) - - setCeilingHoleBoundaryDraft({ - ceilingId, - holeIndex, - polygon: basePolygon, - }) - setCeilingHoleVertexDragState({ - pointerId: event.pointerId, - ceilingId, - holeIndex, - mode: 'edge', - vertexIndex: edgeIndex, - edgeIndex, - edgeNormal, - initialPlanPoint, - initialPolygon: basePolygon, - }) - setCursorPoint(initialPlanPoint) - }, - [displayCeilingPolygons, editingHole, getPlanPointFromClientPoint], - ) const handleSiteVertexPointerDown = useCallback( (siteId: SiteNode['id'], vertexIndex: number, event: ReactPointerEvent) => { if (event.button !== 0) { @@ -16504,168 +7788,17 @@ export function FloorplanPanel() { }, [displaySitePolygon], ) - const handleZoneVertexPointerDown = useCallback( - ( - zoneId: ZoneNodeType['id'], - vertexIndex: number, - event: ReactPointerEvent, - ) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredZoneHandleId(null) - - const zoneEntry = displayZonePolygons.find(({ zone }) => zone.id === zoneId) - const vertexPoint = zoneEntry?.polygon[vertexIndex] - if (!(zoneEntry && vertexPoint)) { - return - } - - setZoneBoundaryDraft({ - zoneId, - polygon: zoneEntry.polygon.map(toWallPlanPoint), - }) - setZoneVertexDragState({ - pointerId: event.pointerId, - zoneId, - vertexIndex, - }) - setCursorPoint(toWallPlanPoint(vertexPoint)) - }, - [displayZonePolygons], - ) - const handleZoneVertexDoubleClick = useCallback( - ( - zoneId: ZoneNodeType['id'], - vertexIndex: number, - event: ReactPointerEvent, - ) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - - const zone = zoneById.get(zoneId) - if (!(zone && zone.polygon.length > 3)) { - return - } - - zoneBoundaryDraftRef.current = null - clearZoneBoundaryInteraction() - - updateNode(zoneId, { - polygon: zone.polygon.filter((_, index) => index !== vertexIndex), - }) - }, - [clearZoneBoundaryInteraction, updateNode, zoneById], - ) - const handleZoneMidpointPointerDown = useCallback( - (zoneId: ZoneNodeType['id'], edgeIndex: number, event: ReactPointerEvent) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - setHoveredZoneHandleId(null) - - const zoneEntry = displayZonePolygons.find(({ zone }) => zone.id === zoneId) - if (!zoneEntry) { - return - } - - const basePolygon = zoneEntry.polygon.map(toWallPlanPoint) - const startPoint = basePolygon[edgeIndex] - const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] - if (!(startPoint && endPoint)) { - return - } - - const insertedPoint: WallPlanPoint = [ - (startPoint[0] + endPoint[0]) / 2, - (startPoint[1] + endPoint[1]) / 2, - ] - const insertIndex = edgeIndex + 1 - const nextPolygon = [ - ...basePolygon.slice(0, insertIndex), - insertedPoint, - ...basePolygon.slice(insertIndex), - ] - - setZoneBoundaryDraft({ - zoneId, - polygon: nextPolygon, - }) - setZoneVertexDragState({ - pointerId: event.pointerId, - zoneId, - vertexIndex: insertIndex, - }) - setCursorPoint(insertedPoint) - }, - [displayZonePolygons], - ) const handlePointerLeave = useCallback(() => { - if ( - !( - panStateRef.current || - wallEndpointDragRef.current || - ceilingVertexDragState || - ceilingHoleMoveDraft || - ceilingHoleVertexDragState || - siteVertexDragState || - slabHoleMoveDraft || - slabHoleVertexDragState || - slabVertexDragState || - zoneVertexDragState - ) - ) { + if (!(panStateRef.current || wallEndpointDragRef.current || siteVertexDragState)) { setCursorPoint(null) } - handleOpeningHoverChange(null) - handleItemHoverChange(null) - handleWallHoverChange(null) - handleSlabHoverChange(null) - handleCeilingHoverChange(null) - handleSpawnHoverChange(null) - handleStairHoverChange(null) - handleElevatorHoverChange(null) - handleZoneHoverChange(null) - setHoveredEndpointId(null) setHoveredSiteHandleId(null) - setHoveredSlabHandleId(null) - setHoveredCeilingHandleId(null) - setHoveredZoneHandleId(null) if (hoveredWallIdRef.current) { emitFloorplanWallLeave(hoveredWallIdRef.current) hoveredWallIdRef.current = null } - }, [ - emitFloorplanWallLeave, - handleCeilingHoverChange, - handleElevatorHoverChange, - handleItemHoverChange, - handleOpeningHoverChange, - handleSlabHoverChange, - handleSpawnHoverChange, - handleStairHoverChange, - handleWallHoverChange, - handleZoneHoverChange, - ceilingVertexDragState, - ceilingHoleMoveDraft, - ceilingHoleVertexDragState, - siteVertexDragState, - slabHoleMoveDraft, - slabHoleVertexDragState, - slabVertexDragState, - zoneVertexDragState, - ]) + }, [emitFloorplanWallLeave, siteVertexDragState]) // Lightweight flag that mirrors the conditions under which // FloorplanCursorIndicatorOverlay renders — used to gate cursor-position @@ -16685,14 +7818,7 @@ export function FloorplanPanel() { !guideInteractionRef.current && !elevatorResizeDragState && !wallEndpointDragRef.current && - !ceilingVertexDragState && - !ceilingHoleMoveDraft && - !ceilingHoleVertexDragState && - !siteVertexDragState && - !slabHoleMoveDraft && - !slabHoleVertexDragState && - !slabVertexDragState && - !zoneVertexDragState + !siteVertexDragState ) { const rect = event.currentTarget.getBoundingClientRect() const nextPosition = { @@ -16714,19 +7840,7 @@ export function FloorplanPanel() { handlePointerMove(event) }, - [ - handlePointerMove, - hasFloorplanCursorIndicator, - ceilingVertexDragState, - ceilingHoleMoveDraft, - ceilingHoleVertexDragState, - elevatorResizeDragState, - siteVertexDragState, - slabHoleMoveDraft, - slabHoleVertexDragState, - slabVertexDragState, - zoneVertexDragState, - ], + [handlePointerMove, hasFloorplanCursorIndicator, elevatorResizeDragState, siteVertexDragState], ) const handleSvgPointerLeave = useCallback(() => { @@ -16757,15 +7871,6 @@ export function FloorplanPanel() { }) } setCursorPoint(snappedPoint) - handleItemHoverChange(null) - handleOpeningHoverChange(null) - handleWallHoverChange(null) - handleSlabHoverChange(null) - handleSpawnHoverChange(null) - handleStairHoverChange(null) - handleElevatorHoverChange(null) - handleZoneHoverChange(null) - setHoveredEndpointId(null) floorplanMarqueeSnapPointRef.current = snappedPoint syncPreviewSelectedIds([]) setFloorplanMarqueeState({ @@ -16778,18 +7883,7 @@ export function FloorplanPanel() { event.currentTarget.setPointerCapture(event.pointerId) }, - [ - getPlanPointFromClientPoint, - handleElevatorHoverChange, - handleItemHoverChange, - handleOpeningHoverChange, - handleSlabHoverChange, - handleSpawnHoverChange, - handleStairHoverChange, - handleWallHoverChange, - handleZoneHoverChange, - syncPreviewSelectedIds, - ], + [getPlanPointFromClientPoint, syncPreviewSelectedIds], ) const handleMarqueePointerMove = useCallback( @@ -16943,20 +8037,7 @@ export function FloorplanPanel() { } setFloorplanCursorPosition(null) - handleOpeningHoverChange(null) - handleWallHoverChange(null) - handleSlabHoverChange(null) - handleZoneHoverChange(null) - setHoveredEndpointId(null) - }, [ - handleOpeningHoverChange, - handleSlabHoverChange, - handleWallHoverChange, - handleZoneHoverChange, - isMarqueeSelectionToolActive, - mode, - syncPreviewSelectedIds, - ]) + }, [isMarqueeSelectionToolActive, mode, syncPreviewSelectedIds]) useEffect(() => { if (mode !== 'delete') { @@ -17096,45 +8177,6 @@ export function FloorplanPanel() { setStructureLayer, site, ]) - const hasDuplicatableFloorplanSelection = Boolean( - selectedItemEntry || - selectedOpeningEntry || - selectedStairEntry || - selectedRoofEntry || - selectedWallEntry, - ) - const handleDuplicateFloorplanSelection = useCallback(() => { - if (selectedWallEntry) { - duplicateSelectedWall() - return - } - if (selectedOpeningEntry) { - duplicateSelectedOpening() - return - } - if (selectedItemEntry) { - duplicateSelectedItem() - return - } - if (selectedStairEntry) { - duplicateSelectedStair() - return - } - if (selectedRoofEntry) { - duplicateSelectedRoof() - } - }, [ - duplicateSelectedWall, - duplicateSelectedItem, - duplicateSelectedOpening, - duplicateSelectedRoof, - duplicateSelectedStair, - selectedItemEntry, - selectedOpeningEntry, - selectedRoofEntry, - selectedStairEntry, - selectedWallEntry, - ]) const activeDraftAnchorPoint = referenceScaleDraft?.start ?? draftStart ?? @@ -17180,10 +8222,6 @@ export function FloorplanPanel() { ref={containerRef} > -
)} - + {/* Floating Move / Duplicate / Delete buttons for registered + kinds. All kinds are registry-driven now, so this is the + only action menu the floor plan mounts. */} + {referenceScaleDraft && (
@@ -17444,6 +8419,7 @@ export function FloorplanPanel() { /> - - - - - - - - - - - - - - - - {/* Zone labels: always visible so users can click to select zones from any mode */} - - )} + {/* Registry-driven floor-plan layer. Iterates kinds whose + NodeDefinition supplies a `floorplan` builder and renders + their SVG via . Sits above the + legacy inline content so newly-registered kinds (shelf + today) overlay on top until their inline equivalent is + removed in their Phase 5 migration PR. + + Wrapped in so registry-driven + kinds receive the same themed palette / units-per-pixel + the legacy layers compute. The hatch pattern id is the + legacy wall hatch — kinds that opt into selection hatch + fills reuse this pattern via fill="url(...)". */} + + + + {/* Cursor-driven placement ghost for movingNode when the + active kind is registry-driven. Renders via a portal + into the floor-plan scene (the data-floorplan-scene + attribute below); see floorplan-registry-move-overlay.tsx. */} + + - - - - - - - - handleSlabEdgePointerDown(nodeId as SlabNode['id'], edgeIndex, event) - } - onHandleHoverChange={setHoveredSlabHandleId} - onMidpointPointerDown={(nodeId, edgeIndex, event) => - handleSlabMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event) - } - onVertexDoubleClick={(nodeId, vertexIndex, event) => - handleSlabVertexDoubleClick(nodeId as SlabNode['id'], vertexIndex, event) - } - onVertexPointerDown={(nodeId, vertexIndex, event) => - handleSlabVertexPointerDown(nodeId as SlabNode['id'], vertexIndex, event) - } - palette={palette} - unitsPerPixel={floorplanUnitsPerPixel} - vertexHandles={slabVertexHandles} - /> - - - handleSlabHoleEdgePointerDown(nodeId as SlabNode['id'], edgeIndex, event) - } - onHandleHoverChange={setHoveredSlabHandleId} - onMidpointPointerDown={(nodeId, edgeIndex, event) => - handleSlabHoleMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event) - } - onVertexDoubleClick={(nodeId, vertexIndex, event) => - handleSlabHoleVertexDoubleClick(nodeId as SlabNode['id'], vertexIndex, event) - } - onVertexPointerDown={(nodeId, vertexIndex, event) => - handleSlabHoleVertexPointerDown(nodeId as SlabNode['id'], vertexIndex, event) - } - palette={palette} - unitsPerPixel={floorplanUnitsPerPixel} - vertexHandles={slabHoleVertexHandles} - /> - - - handleCeilingEdgePointerDown(nodeId as CeilingNode['id'], edgeIndex, event) - } - onHandleHoverChange={setHoveredCeilingHandleId} - onMidpointPointerDown={(nodeId, edgeIndex, event) => - handleCeilingMidpointPointerDown(nodeId as CeilingNode['id'], edgeIndex, event) - } - onVertexDoubleClick={(nodeId, vertexIndex, event) => - handleCeilingVertexDoubleClick(nodeId as CeilingNode['id'], vertexIndex, event) - } - onVertexPointerDown={(nodeId, vertexIndex, event) => - handleCeilingVertexPointerDown(nodeId as CeilingNode['id'], vertexIndex, event) - } - palette={palette} - unitsPerPixel={floorplanUnitsPerPixel} - vertexHandles={ceilingVertexHandles} - /> - - - handleCeilingHoleEdgePointerDown(nodeId as CeilingNode['id'], edgeIndex, event) - } - onHandleHoverChange={setHoveredCeilingHandleId} - onMidpointPointerDown={(nodeId, edgeIndex, event) => - handleCeilingHoleMidpointPointerDown( - nodeId as CeilingNode['id'], - edgeIndex, - event, - ) - } - onVertexDoubleClick={(nodeId, vertexIndex, event) => - handleCeilingHoleVertexDoubleClick( - nodeId as CeilingNode['id'], - vertexIndex, - event, - ) - } - onVertexPointerDown={(nodeId, vertexIndex, event) => - handleCeilingHoleVertexPointerDown( - nodeId as CeilingNode['id'], - vertexIndex, - event, - ) - } - palette={palette} - unitsPerPixel={floorplanUnitsPerPixel} - vertexHandles={ceilingHoleVertexHandles} - /> - - - handleZoneMidpointPointerDown(nodeId as ZoneNodeType['id'], edgeIndex, event) - } - onVertexDoubleClick={(nodeId, vertexIndex, event) => - handleZoneVertexDoubleClick(nodeId as ZoneNodeType['id'], vertexIndex, event) - } - onVertexPointerDown={(nodeId, vertexIndex, event) => - handleZoneVertexPointerDown(nodeId as ZoneNodeType['id'], vertexIndex, event) - } - palette={palette} - unitsPerPixel={floorplanUnitsPerPixel} - vertexHandles={zoneVertexHandles} - /> + {/* Wall / fence endpoint, wall curve, slab / ceiling / + zone vertex+midpoint+edge handles are all driven by the + registry's `def.floorplanAffordances` and rendered as + part of `FloorplanRegistryLayer`. The legacy handle + layers that lived here received empty handle arrays + post-migration and rendered nothing. */} {selectedGuide && showGuides && ( { + if (!(object as Mesh).isMesh) return + restores.push(previewMeshMaterial(object as Mesh, previewMaterial)) + }) + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.() + } + } + } + if (!mesh) return null if (node.type === 'slab') { @@ -648,6 +669,11 @@ const SELECTION_STRATEGIES: Record = { } if (node.type === 'window' || node.type === 'door') return true + // Registry-driven: any kind whose NodeDefinition declares the + // `selectable` capability is also selectable in structure phase. Phase 4 + // makes this the only path and deletes the hardcoded chain above. + if (isRegistrySelectable(node.type)) return true + return false }, }, @@ -676,14 +702,43 @@ const SELECTION_STRATEGIES: Record = { }, isValid: (node) => { if (!isNodeInCurrentLevel(node)) return false - if (node.type !== 'item') return false - const item = node as ItemNode - return item.asset.category !== 'door' && item.asset.category !== 'window' + // Item: door/window-category items belong to structure phase, not furnish. + if (node.type === 'item') { + const item = node as ItemNode + return item.asset.category !== 'door' && item.asset.category !== 'window' + } + // Registry-driven kinds with `category: 'furnish'` (shelf today, + // future furniture kinds): selectable in furnish phase if their + // definition declares the `selectable` capability. Without this + // branch, shelf clicks routed to furnish phase via getSelectionTarget + // would be rejected here — single-click selection broken. + const def = nodeRegistry.get(node.type) + if (def && def.category === 'furnish' && def.capabilities.selectable) return true + return false }, }, } const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { + // Item is checked FIRST so its asset.category-driven routing (door/ + // window items land in structure phase, everything else in furnish) + // beats the generic registry fallback below. Without this, registering + // `item` (Phase 5) made isRegistrySelectable('item') match the + // structure branch first, breaking single-click selection: first click + // switched the editor to structure phase, second click selected. + if (node.type === 'item') { + const item = node as ItemNode + if (item.asset.category === 'door' || item.asset.category === 'window') { + return { + phase: 'structure', + structureLayer: 'elements', + } + } + return { + phase: 'furnish', + } + } + if (node.type === 'zone') { return { phase: 'structure', @@ -712,18 +767,16 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { } } - if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.category === 'door' || item.asset.category === 'window') { - return { - phase: 'structure', - structureLayer: 'elements', - } - } - - return { - phase: 'furnish', + // Registry-driven kinds (Phase 5+): route by `def.category`. Built-ins + // above match before this fallback. Furnish-category kinds (shelf, + // item — already handled above) land on the furnish phase; structure- + // category kinds (everything else) on structure/elements. + const def = nodeRegistry.get(node.type) + if (def) { + if (def.category === 'furnish') { + return { phase: 'furnish' } } + return { phase: 'structure', structureLayer: 'elements' } } return null @@ -899,7 +952,8 @@ export const SelectionManager = () => { node.type === 'fence' || node.type === 'column' || node.type === 'slab' || - node.type === 'ceiling' + node.type === 'ceiling' || + node.type === 'shelf' ) { const compatible = hasActivePaintMaterial(activePaintMaterial) @@ -914,7 +968,7 @@ export const SelectionManager = () => { .updateNode( node.id as AnyNodeId, buildSingleSurfaceMaterialPatch< - FenceNode | ColumnNode | SlabNode | CeilingNode + FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode >(activePaintMaterial.material, activePaintMaterial.materialPreset), ) } @@ -922,7 +976,7 @@ export const SelectionManager = () => { preview: compatible ? () => applySingleSurfacePaintPreview( - node as FenceNode | ColumnNode | SlabNode | CeilingNode, + node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode, activePaintMaterial, ) : () => previewCursor('not-allowed'), @@ -1017,14 +1071,21 @@ export const SelectionManager = () => { 'zone', ] as const - for (const type of allTypes) { + // Registry-driven kinds get the same subscriptions as the hardcoded list, + // so future built-in nodes don't need to edit allTypes per migration. + const registryKinds = getSelectableKinds().filter( + (k) => !(allTypes as readonly string[]).includes(k), + ) + const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds] + + for (const type of subscribedKinds) { emitter.on(`${type}:enter` as any, onEnter as any) emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:click` as any, onClick as any) } return () => { - for (const type of allTypes) { + for (const type of subscribedKinds) { emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:click` as any, onClick as any) @@ -1151,7 +1212,10 @@ export const SelectionManager = () => { } if ( - (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') && + (node.type === 'fence' || + node.type === 'slab' || + node.type === 'ceiling' || + node.type === 'shelf') && nodeToSelect.type === node.type ) { setSelectedMaterialTargetForNode(nodeToSelect, 'surface') @@ -1187,7 +1251,14 @@ export const SelectionManager = () => { 'window', 'door', ] - allTypes.forEach((type) => { + // Registry-driven kinds get the same subscriptions as the hardcoded list, + // so future built-in nodes don't need to edit allTypes per migration. + const registryKinds = getSelectableKinds().filter( + (k) => !(allTypes as readonly string[]).includes(k), + ) + const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds] + + subscribedKinds.forEach((type) => { emitter.on(`${type}:click` as any, onClick as any) }) @@ -1208,7 +1279,7 @@ export const SelectionManager = () => { emitter.on('grid:click', onGridClick) return () => { - allTypes.forEach((type) => { + subscribedKinds.forEach((type) => { emitter.off(`${type}:click` as any, onClick as any) }) emitter.off('grid:click', onGridClick) @@ -1340,14 +1411,19 @@ export const SelectionManager = () => { 'zone', 'site', ] - allTypes.forEach((type) => { + const registryKinds = getSelectableKinds().filter( + (k) => !(allTypes as readonly string[]).includes(k), + ) + const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds] + + subscribedKinds.forEach((type) => { emitter.on(`${type}:enter` as any, onEnter as any) emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:double-click` as any, onDoubleClick as any) }) return () => { - allTypes.forEach((type) => { + subscribedKinds.forEach((type) => { emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:double-click` as any, onDoubleClick as any) @@ -1414,14 +1490,19 @@ export const SelectionManager = () => { 'zone', ] as const - for (const type of allTypes) { + const registryKinds = getSelectableKinds().filter( + (k) => !(allTypes as readonly string[]).includes(k), + ) + const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds] + + for (const type of subscribedKinds) { emitter.on(`${type}:click` as any, onClick as any) emitter.on(`${type}:enter` as any, onEnter as any) emitter.on(`${type}:leave` as any, onLeave as any) } return () => { - for (const type of allTypes) { + for (const type of subscribedKinds) { emitter.off(`${type}:click` as any, onClick as any) emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:leave` as any, onLeave as any) diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index b1135943..832e36c6 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -207,7 +207,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const restoreNodeVisibility = (() => { const saved = new Map() for (const type of ['scan', 'guide'] as const) { - const ids = sceneRegistry.byType[type] + const ids = sceneRegistry.byType[type]! ids.forEach((id) => { const node = sceneRegistry.nodes.get(id) if (node) { diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index d6228200..41e93713 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -179,12 +179,11 @@ export function useFloorplanBackgroundPlacement({ return true } - if (isFloorplanGridInteractionActive) { - const snappedPoint = emitFloorplanGridEvent('click', planPoint, event) - setCursorPoint(snappedPoint) - return true - } - + // Slab / zone polygon build — local draft state + grid emit. + // Must run BEFORE the `isFloorplanGridInteractionActive` catch-all + // (since slab is registry-driven, the catch-all would otherwise + // swallow the click and skip local draft state updates — leaving + // the 2D draft polygon invisible while the 3D tool builds fine). if (isPolygonBuildActive) { const snappedPoint = snapPolygonDraftPoint({ point: planPoint, @@ -192,6 +191,13 @@ export function useFloorplanBackgroundPlacement({ angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, }) + // Emit the grid event so the registry-driven slab tool also + // sees the click (parity with ceiling / fence / roof branches + // above). Zone has no registry tool — emit-or-not is irrelevant. + if (!isZoneBuildActive) { + emitFloorplanGridEvent('click', snappedPoint, event) + } + if (isZoneBuildActive) { handleZonePlacementPoint(snappedPoint) } else { @@ -200,19 +206,34 @@ export function useFloorplanBackgroundPlacement({ return true } - if (!isWallBuildActive) { - return false + // Wall placement — local draft state + grid emit. Same reasoning + // as slab above: wall is registry-driven, so without this branch + // the catch-all would swallow the click and the local draftStart + // / draftEnd state in the floor plan would never update, leaving + // the dashed-line draft preview invisible. + if (isWallBuildActive) { + const snappedPoint = snapWallDraftPoint({ + point: planPoint, + walls, + start: draftStart ?? undefined, + angleSnap: Boolean(draftStart) && !shiftPressed, + }) + + emitFloorplanGridEvent('click', snappedPoint, event) + handleWallPlacementPoint(snappedPoint) + return true } - const snappedPoint = snapWallDraftPoint({ - point: planPoint, - walls, - start: draftStart ?? undefined, - angleSnap: Boolean(draftStart) && !shiftPressed, - }) + // 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 + } - handleWallPlacementPoint(snappedPoint) - return true + return false }, [ activePolygonDraftPoints, diff --git a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx index f90d8da3..71917ba4 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx @@ -46,7 +46,7 @@ export const CeilingSystem = () => { } } - const ceilings = sceneRegistry.byType.ceiling + const ceilings = sceneRegistry.byType.ceiling! ceilings.forEach((ceiling) => { const mesh = sceneRegistry.nodes.get(ceiling) if (mesh) { diff --git a/packages/editor/src/components/tools/building/move-building-tool.tsx b/packages/editor/src/components/tools/building/move-building-tool.tsx index 0a1e96fd..8925119b 100644 --- a/packages/editor/src/components/tools/building/move-building-tool.tsx +++ b/packages/editor/src/components/tools/building/move-building-tool.tsx @@ -8,7 +8,6 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useFrame } from '@react-three/fiber' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' diff --git a/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx b/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx deleted file mode 100644 index ce14f8a7..00000000 --- a/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx +++ /dev/null @@ -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(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> - } | 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>(node.polygon) - const [previewHoles, setPreviewHoles] = useState>>(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>, - ) => { - 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 ( - - - - - {/* @ts-ignore */} - - - - - - ) -} - -function createCeilingPreviewGeometry( - polygon: Array<[number, number]>, - holes: Array>, -): 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 -} diff --git a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx deleted file mode 100644 index bc3f643d..00000000 --- a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx +++ /dev/null @@ -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(Date.now()) - const previousGridPosRef = useRef(null) - const shiftPressedRef = useRef(false) - const altPressedRef = useRef(false) - const nodeIdRef = useRef(target.fence.id) - const originalStartRef = useRef([...target.fence.start] as FencePlanPoint) - const originalEndRef = useRef([...target.fence.end] as FencePlanPoint) - const originalMovingPointRef = useRef( - target.endpoint === 'start' - ? ([...target.fence.start] as FencePlanPoint) - : ([...target.fence.end] as FencePlanPoint), - ) - const fixedPointRef = useRef( - 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(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 ( - - - -
-
- {altPressed ? 'Detach endpoint' : 'Drag endpoint'} -
-
- - {angleLabel && } -
- ) -} - -function EndpointAngleLabel({ - label, - position, -}: { - label: string - position: [number, number, number] -}) { - return ( - -
- {label} -
- - ) -} diff --git a/packages/editor/src/components/tools/item/item-tool.tsx b/packages/editor/src/components/tools/item/item-tool.tsx deleted file mode 100644 index 6bb1ffae..00000000 --- a/packages/editor/src/components/tools/item/item-tool.tsx +++ /dev/null @@ -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 -} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5b017ed2..d7c86be9 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,121 +1,66 @@ import type { AnyNodeId, BuildingNode, - CeilingNode, - ColumnNode, - DoorNode, ElevatorNode, - FenceNode, - ItemNode, RoofNode, RoofSegmentNode, - SlabNode, SpawnNode, StairNode, StairSegmentNode, - WallNode, - WindowNode, } from '@pascal-app/core' -import { Vector3 } from 'three' -import { sfxEmitter } from '../../../lib/sfx-bus' +import { nodeRegistry } from '@pascal-app/core' +import { Suspense } from 'react' import useEditor from '../../../store/use-editor' import { MoveBuildingContent } from '../building/move-building-tool' -import { MoveCeilingTool } from '../ceiling/move-ceiling-tool' -import { MoveColumnTool } from '../column/move-column-tool' -import { MoveDoorTool } from '../door/move-door-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' -import { MoveFenceTool } from '../fence/move-fence-tool' +import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} +import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' +/** + * MoveTool dispatcher. Routes to (in order): + * + * 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that + * declare `capabilities.movable` (shelf, spawn, item-with-floor-attach, + * …). + * 2. `def.affordanceTools.move` — kind-owned move component + * (slab / ceiling / wall / fence / column / item / door / window). + * Lazy-loaded via `getRegistryAffordanceTool`. + * 3. The narrow set of kinds that still have legacy movers because no + * registry equivalent has been written yet (building / elevator / + * roof / stair). Each of these has bespoke move semantics that + * don't fit the generic mover and are not yet ported to a + * kind-owned affordance. + */ export const MoveTool: React.FC<{ onNodeMoved?: (nodeId: AnyNodeId) => void onSpawnMoved?: (nodeId: SpawnNode['id']) => void -}> = ({ onNodeMoved, onSpawnMoved }) => { +}> = ({ onNodeMoved }) => { const movingNode = useEditor((state) => state.movingNode) if (!movingNode) return null + + const def = nodeRegistry.get(movingNode.type) + if (def?.capabilities?.movable) { + return + } + + const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move') + if (RegistryMove) { + return ( + + + + ) + } + if (movingNode.type === 'building') return - if (movingNode.type === 'door') return if (movingNode.type === 'elevator') return - if (movingNode.type === 'window') return - if (movingNode.type === 'ceiling') return - if (movingNode.type === 'column') return - if (movingNode.type === 'slab') return - if (movingNode.type === 'wall') return - if (movingNode.type === 'fence') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return - if (movingNode.type === 'spawn') - return if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') return - return + return null } diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e872408..e74352d9 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,15 @@ import type { GridEvent, ItemEvent, ItemNode, + ShelfEvent, + ShelfNode, WallEvent, WallNode, } from '@pascal-app/core' import { getScaledDimensions, isLowProfileItemSurface, + nodeRegistry, sceneRegistry, useScene, } from '@pascal-app/core' @@ -587,6 +590,156 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// SHELF SURFACE STRATEGY +// ============================================================================ + +/** + * Resolve the row Y closest to the cursor's local Y. Reads candidate row + * positions from the kind's `capabilities.surfaces.custom` — the shelf + * declaration emits one `SurfacePoint` per board's top surface. The + * strategy stays kind-agnostic at this level: any future "multi-board" + * kind that declares `surfaces.custom` with upward normals gets the + * same hit behaviour for free. + */ +function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null { + const def = nodeRegistry.get('shelf') + const custom = def?.capabilities?.surfaces?.custom + if (!custom) return null + const candidates = custom(shelfNode as AnyNode) + if (candidates.length === 0) return null + let best = candidates[0] + let bestDist = Math.abs(best!.position[1] - localY) + for (let i = 1; i < candidates.length; i++) { + const c = candidates[i] + if (!c) continue + const dist = Math.abs(c.position[1] - localY) + if (dist < bestDist) { + best = c + bestDist = dist + } + } + return best?.position[1] ?? null +} + +export const shelfSurfaceStrategy = { + /** + * Handle shelf:enter — transition the draft onto the closest shelf + * row. Mirrors `itemSurfaceStrategy.enter` but reads candidate + * surface heights from the shelf kind's `surfaces.custom` (one Y per + * board) instead of `asset.surface.height`. Picks the row whose + * surface Y is nearest the cursor's local Y so the user can target a + * specific row by hovering near it. + */ + enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + const shelfNode = event.node as ShelfNode + + if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) { + return null + } + if (!isUpwardShelfSurfaceHit(event)) return null + + // Size check: draft footprint must fit on the shelf board (width × depth). + const ourDims = ctx.draftItem + ? getScaledDimensions(ctx.draftItem) + : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS) + if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null + + const shelfMesh = sceneRegistry.nodes.get(shelfNode.id) + if (!shelfMesh) return null + + const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) + const localPos = shelfMesh.worldToLocal(worldPos) + const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) + if (rowY === null) return null + + const x = snapToGrid(localPos.x, ourDims[0]) + const z = snapToGrid(localPos.z, ourDims[2]) + + const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) + + const surfaceQuat = new Quaternion() + shelfMesh.getWorldQuaternion(surfaceQuat) + const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y + const localRotationY = ctx.currentCursorRotationY - surfaceWorldY + const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0] + + return { + stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id }, + nodeUpdate: { + position: [x, rowY, z], + parentId: shelfNode.id, + rotation: [draftRotation[0], localRotationY, draftRotation[2]], + }, + cursorRotationY: ctx.currentCursorRotationY, + gridPosition: [x, rowY, z], + cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], + stopPropagation: true, + } + }, + + /** + * Handle shelf:move — re-derive the closest row each tick so the user + * can slide between rows without leaving the shelf. + */ + move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null { + if (ctx.state.surface !== 'shelf-surface') return null + if (!(ctx.state.shelfId && ctx.draftItem)) return null + if (event.node.id !== ctx.state.shelfId) return null + + const shelfNode = event.node as ShelfNode + const shelfMesh = sceneRegistry.nodes.get(shelfNode.id) + if (!shelfMesh) return null + + const ourDims = getScaledDimensions(ctx.draftItem) + const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) + const localPos = shelfMesh.worldToLocal(worldPos) + const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) + if (rowY === null) return null + + const x = snapToGrid(localPos.x, ourDims[0]) + const z = snapToGrid(localPos.z, ourDims[2]) + const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) + + return { + gridPosition: [x, rowY, z], + cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], + cursorRotationY: ctx.currentCursorRotationY, + nodeUpdate: { position: [x, rowY, z] }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + /** + * Handle shelf:click — commit placement on the active row. + */ + click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null { + if (ctx.state.surface !== 'shelf-surface') return null + if (!(ctx.draftItem && ctx.state.shelfId)) return null + if (event.node.id !== ctx.state.shelfId) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.state.shelfId, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, +} + +/** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed + * for `ShelfEvent`. Re-uses the matrix-driven world normal calculation + * via a tiny `ItemEvent`-shaped adapter — the function only reads + * `event.normal` + `event.object`. */ +function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { + return isUpwardItemSurfaceHit(event as unknown as ItemEvent) +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +756,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Shelf surface: same — size check already happened on enter + if (ctx.state.surface === 'shelf-surface') { + return ctx.state.shelfId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 53828658..66337484 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,13 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + /** + * Active shelf when `surface === 'shelf-surface'`. Items host on the + * shelf board closest to the cursor's local Y; the row index isn't + * stored separately because every move re-derives it from cursor + * position via `shelfRowSurfaceYs`. + */ + shelfId: string | null } // ============================================================================ diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index 348422c6..67b72149 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -179,9 +179,28 @@ export function useDraftNode(): DraftNodeHandle { if (!draftRef.current) return if (adoptedRef.current && originalStateRef.current) { - // Move mode: restore original state instead of deleting + // Move mode: restore original state instead of deleting — but only + // if no other system has already committed a new position for this + // node. The 2D `FloorplanRegistryMoveOverlay` commits via + // `useScene.updateNodes` before unmounting the legacy mover, and + // an unconditional restore here would wipe that commit. By + // comparing the live state to the snapshot we took in `adopt()`, + // we let an external committer's write stick. const original = originalStateRef.current const id = draftRef.current.id + const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined + const livePosition = live?.position + const externallyMoved = + !!livePosition && + (livePosition[0] !== original.position[0] || + livePosition[1] !== original.position[1] || + livePosition[2] !== original.position[2]) + if (externallyMoved) { + draftRef.current = null + adoptedRef.current = false + originalStateRef.current = null + return + } useScene.getState().updateNode(id, { position: original.position, diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fdafe363..38c845bf 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type ShelfEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + shelfSurfaceStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,10 +288,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { + surface: 'floor', + wallId: null, + ceilingId: null, + surfaceItemId: null, + shelfId: null, + }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(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(null) // Store config callbacks in refs to avoid re-running effect when they change @@ -435,6 +451,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea wallId: null, ceilingId: null, surfaceItemId: null, + shelfId: null, } if (!asset.attachTo && placementState.current.surface === 'floor') { gridPosition.current.y = 0 @@ -552,6 +569,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea configRef.current.initDraft(gridPosition.current) } + has3DPointerDrivenMoveRef.current = true lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2]) const result = floorStrategy.move(getContext(), event) if (!result) return @@ -609,6 +627,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Wall Handlers ---- const onWallEnter = (event: WallEvent) => { + has3DPointerDrivenMoveRef.current = true const nodes = useScene.getState().nodes const result = wallStrategy.enter( getContext(), @@ -634,6 +653,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onWallMove = (event: WallEvent) => { + has3DPointerDrivenMoveRef.current = true const ctx = getContext() if (ctx.state.surface !== 'wall') { @@ -824,6 +844,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onItemEnter = (event: ItemEvent) => { if (event.node.id === draftNode.current?.id) return + has3DPointerDrivenMoveRef.current = true const result = itemSurfaceStrategy.enter(getContext(), event) if (!result) return @@ -840,6 +861,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onItemMove = (event: ItemEvent) => { if (event.node.id === draftNode.current?.id) return + has3DPointerDrivenMoveRef.current = true const ctx = getContext() if (ctx.state.surface !== 'item-surface') { @@ -923,7 +945,102 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onItemClick = (event: ItemEvent) => { - if (event.node.id === draftNode.current?.id) return + // Click on the draft item itself. R3F dispatches click events to + // the closest intersected mesh only — when the draft is hovering + // on a host (shelf / table / etc.) the draft's mesh is *above* + // the host's mesh, so the host's `${kind}:click` never fires. + // If we're currently hosting on a shelf-surface, treat the + // self-click as a commit on the active shelf so the user doesn't + // have to aim around the cursor preview to drop the item. + if (event.node.id === draftNode.current?.id) { + const ctx = getContext() + if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) { + const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId] + if (shelfNode && shelfNode.type === 'shelf') { + const synthetic = { ...event, node: shelfNode } as unknown as ItemEvent + const result = shelfSurfaceStrategy.click(ctx, synthetic as never) + if (result) { + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + if (configRef.current.onCommitted()) { + const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never) + if (enterResult) { + applyTransition(enterResult) + } else { + revalidate() + } + } + return + } + } + } + // Same self-click forwarding for item-surface hosts (tables, + // counters) — the draft mesh sits on top of the host mesh, so + // the host's own click event is blocked by the cursor preview. + if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) { + const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId] + if (hostNode && hostNode.type === 'item') { + const synthetic = { ...event, node: hostNode } as ItemEvent + const result = itemSurfaceStrategy.click(ctx, synthetic) + if (result) { + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + if (configRef.current.onCommitted()) { + const enterResult = itemSurfaceStrategy.enter(ctx, synthetic) + if (enterResult) { + applyTransition(enterResult) + } else { + revalidate() + } + } + return + } + } + } + // Ceiling-hosted draft: when placing a ceiling-attached item the + // draft hangs below the ceiling and intercepts the click ray + // before the ceiling-grid mesh does — so `ceiling:click` never + // fires and the user's commit click is dropped. Forward the + // self-click to `ceilingStrategy.click` so placement commits the + // same way it would from a click on the ceiling itself. + if (ctx.state.surface === 'ceiling' && ctx.state.ceilingId) { + const ceilingNode = useScene.getState().nodes[ctx.state.ceilingId as AnyNodeId] + if (ceilingNode && ceilingNode.type === 'ceiling') { + const synthetic = { ...event, node: ceilingNode } as unknown as CeilingEvent + const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators()) + if (result) { + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + if (configRef.current.onCommitted()) { + const nodes = useScene.getState().nodes + const enterResult = ceilingStrategy.enter( + getContext(), + synthetic, + resolveLevelId, + nodes, + ) + if (enterResult) { + applyTransition(enterResult) + } else { + revalidate() + } + } + return + } + } + } + return + } + const result = itemSurfaceStrategy.click(getContext(), event) if (!result) return @@ -948,6 +1065,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Ceiling Handlers ---- const onCeilingEnter = (event: CeilingEvent) => { + has3DPointerDrivenMoveRef.current = true const nodes = useScene.getState().nodes const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes) if (!result) return @@ -967,6 +1085,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onCeilingMove = (event: CeilingEvent) => { + has3DPointerDrivenMoveRef.current = true if (!draftNode.current && placementState.current.surface === 'ceiling') { const nodes = useScene.getState().nodes const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes) @@ -1065,6 +1184,100 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Shelf Handlers ---- + // + // Items can host on shelves the same way they host on tables and + // counters (item-surface). The shelf's `surfaces.custom` exposes one + // candidate Y per row; `shelfSurfaceStrategy` picks the closest one + // to the cursor's local-Y so the user can target a specific row. + + const onShelfEnter = (event: ShelfEvent) => { + has3DPointerDrivenMoveRef.current = true + const result = shelfSurfaceStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + applyTransition(result) + + if (!draftNode.current) { + ensureDraft(result) + } else if (result.nodeUpdate.parentId) { + useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate) + } + } + + const onShelfMove = (event: ShelfEvent) => { + has3DPointerDrivenMoveRef.current = true + const ctx = getContext() + if (ctx.state.surface !== 'shelf-surface') { + // Cursor entered via a move event without an enter — try + // transitioning in so the user doesn't need to mouse out + back + // in to start hosting. + const enterResult = shelfSurfaceStrategy.enter(ctx, event) + if (!enterResult) return + event.stopPropagation() + applyTransition(enterResult) + if (!draftNode.current) { + ensureDraft(enterResult) + } else if (enterResult.nodeUpdate.parentId) { + useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate) + } + return + } + const result = shelfSurfaceStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + gridPosition.current.set(...result.gridPosition) + const ic = worldToBuildingLocal(...result.cursorPosition) + cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) + cursorGroupRef.current.rotation.y = result.cursorRotationY + + const draft = draftNode.current + if (draft) { + draft.position = result.gridPosition + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) mesh.position.set(...result.gridPosition) + useLiveTransforms.getState().set(draft.id, { + position: result.cursorPosition, + rotation: result.cursorRotationY, + }) + } + + revalidate() + } + + const onShelfLeave = (event: ShelfEvent) => { + if (placementState.current.surface !== 'shelf-surface') return + if (event.node.id !== placementState.current.shelfId) return + event.stopPropagation() + // Drop back to floor — same pattern as item-leave but without the + // detachItemSurfaceToFloor (no scaled rotation hand-off to deal + // with since the shelf rotation already composed cleanly). + Object.assign(placementState.current, { surface: 'floor', shelfId: null }) + } + + const onShelfClick = (event: ShelfEvent) => { + const result = shelfSurfaceStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + const enterResult = shelfSurfaceStrategy.enter(getContext(), event) + if (enterResult) { + applyTransition(enterResult) + } else { + revalidate() + } + } + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1452,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('shelf:enter', onShelfEnter) + emitter.on('shelf:move', onShelfMove) + emitter.on('shelf:click', onShelfClick) + emitter.on('shelf:leave', onShelfLeave) return () => { tearingDown = true @@ -1263,6 +1480,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('shelf:enter', onShelfEnter) + emitter.off('shelf:move', onShelfMove) + emitter.off('shelf:click', onShelfClick) + emitter.off('shelf:leave', onShelfLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1297,6 +1518,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea useFrame((_, delta) => { if (!asset) return if (!draftNode.current) return + // The mesh-position lerp below only makes sense once this coordinator + // owns the move via a 3D pointer event. Skip until then so that + // external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay` + // writing scene.position directly) aren't fought by useFrame pulling + // the mesh back to its pre-move location. + if (!has3DPointerDrivenMoveRef.current) return const mesh = sceneRegistry.nodes.get(draftNode.current.id) if (!mesh) return diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx new file mode 100644 index 00000000..f070c9d0 --- /dev/null +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -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 + +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) + 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), + 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}:${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 +} diff --git a/packages/editor/src/components/tools/shared/affordance-dispatch.ts b/packages/editor/src/components/tools/shared/affordance-dispatch.ts new file mode 100644 index 00000000..0fc35c6a --- /dev/null +++ b/packages/editor/src/components/tools/shared/affordance-dispatch.ts @@ -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[]: () => import('./-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, ComponentType>() + +export function getRegistryAffordanceTool( + kind: string, + affordance: string, +): ComponentType | 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 }>) + lazyToolCache.set(loader, Comp as unknown as ComponentType) + return Comp as unknown as ComponentType +} diff --git a/packages/editor/src/components/tools/slab/move-slab-tool.tsx b/packages/editor/src/components/tools/slab/move-slab-tool.tsx deleted file mode 100644 index d7baebcc..00000000 --- a/packages/editor/src/components/tools/slab/move-slab-tool.tsx +++ /dev/null @@ -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(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> - } | 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>, - ) => { - 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 ( - - - - ) -} diff --git a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx deleted file mode 100644 index 6eef48a6..00000000 --- a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/packages/editor/src/components/tools/spawn/spawn-tool.tsx b/packages/editor/src/components/tools/spawn/spawn-tool.tsx deleted file mode 100644 index 30d294ee..00000000 --- a/packages/editor/src/components/tools/spawn/spawn-tool.tsx +++ /dev/null @@ -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 - Spawn Point -) - -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 = ({ currentLevelId, onPlaced }) => { - const [, setCursorPosition] = useState<[number, number, number] | null>(null) - const cursorRef = useRef(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 ( - - ) -} diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 73d033cc..c82711b9 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -1,5 +1,4 @@ import { - type AnyNode, emitter, type GridEvent, type LevelNode, diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index cd9518e6..69633b5f 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -2,55 +2,51 @@ import { type AnyNodeId, type BuildingNode, type CeilingNode, + nodeRegistry, type SlabNode, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { type ComponentType, lazy, Suspense } from 'react' import useEditor, { type Phase, type Tool } from '../../store/use-editor' -import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' -import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' -import { CeilingTool } from './ceiling/ceiling-tool' import { ColumnTool } from './column/column-tool' -import { DoorTool } from './door/door-tool' import { ElevatorTool } from './elevator/elevator-tool' -import { CurveFenceTool } from './fence/curve-fence-tool' -import { FenceTool } from './fence/fence-tool' -import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool' -import { ItemTool } from './item/item-tool' import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' +import { getRegistryAffordanceTool } from './shared/affordance-dispatch' import { SiteBoundaryEditor } from './site/site-boundary-editor' -import { SlabBoundaryEditor } from './slab/slab-boundary-editor' -import { SlabHoleEditor } from './slab/slab-hole-editor' -import { SlabTool } from './slab/slab-tool' -import { SpawnTool } from './spawn/spawn-tool' import { StairTool } from './stair/stair-tool' -import { CurveWallTool } from './wall/curve-wall-tool' -import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool' -import { WallTool } from './wall/wall-tool' -import { WindowTool } from './window/window-tool' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneTool } from './zone/zone-tool' +// Cache lazy tool components keyed by their loader so React.lazy isn't +// re-invoked across renders. +const lazyToolCache = new WeakMap<() => Promise, 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>> = { site: { 'property-line': SiteBoundaryEditor, }, structure: { - wall: WallTool, - fence: FenceTool, - slab: SlabTool, - ceiling: CeilingTool, roof: RoofTool, stair: StairTool, - door: DoorTool, - item: ItemTool, zone: ZoneTool, - window: WindowTool, - }, - furnish: { - item: ItemTool, }, + furnish: {}, } export const ToolManager: React.FC = () => { @@ -127,7 +123,12 @@ export const ToolManager: React.FC = () => { // Show build tools when in build mode const showBuildTool = mode === 'build' && tool !== null - const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null + // Registry-first: if the active tool's kind has a NodeDefinition with a + // tool contribution, the registry-driven tool takes over. + const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null + const useRegistryTool = RegistryToolComponent != null + + const BuildToolComponent = showBuildTool && !useRegistryTool ? tools[phase]?.[tool] : null const handlePlacedNodeSelected = (nodeId: AnyNodeId) => { setSelection({ selectedIds: [nodeId] }) } @@ -154,44 +155,114 @@ export const ToolManager: React.FC = () => { rotation={buildingRotation as [number, number, number]} > {showZoneBoundaryEditor && selectedZoneId && } - {showSlabBoundaryEditor && selectedSlabId && } - {showSlabHoleEditor && selectedSlabId && editingHole && ( - - )} - {showCeilingBoundaryEditor && selectedCeilingId && ( - - )} - {showCeilingHoleEditor && selectedCeilingId && editingHole && ( - - )} - {movingWallEndpoint && } - {movingFenceEndpoint && } - {curvingWall && } - {curvingFence && } + {showSlabBoundaryEditor && + selectedSlabId && + (() => { + const Registry = getRegistryAffordanceTool('slab', 'boundary-edit') + return Registry ? ( + + + + ) : null + })()} + {showSlabHoleEditor && + selectedSlabId && + editingHole && + (() => { + const Registry = getRegistryAffordanceTool('slab', 'hole-edit') + return Registry ? ( + + + + ) : null + })()} + {showCeilingBoundaryEditor && + selectedCeilingId && + (() => { + const Registry = getRegistryAffordanceTool('ceiling', 'boundary-edit') + return Registry ? ( + + + + ) : null + })()} + {showCeilingHoleEditor && + selectedCeilingId && + editingHole && + (() => { + const Registry = getRegistryAffordanceTool('ceiling', 'hole-edit') + return Registry ? ( + + + + ) : null + })()} + {movingWallEndpoint && + (() => { + const RegistryAffordance = getRegistryAffordanceTool( + movingWallEndpoint.wall.type, + 'move-endpoint', + ) + return RegistryAffordance ? ( + + + + ) : null + })()} + {movingFenceEndpoint && + (() => { + const RegistryAffordance = getRegistryAffordanceTool( + movingFenceEndpoint.fence.type, + 'move-endpoint', + ) + return RegistryAffordance ? ( + + + + ) : null + })()} + {curvingWall && + (() => { + const Registry = getRegistryAffordanceTool(curvingWall.type, 'curve') + return Registry ? ( + + + + ) : null + })()} + {curvingFence && + (() => { + const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve') + return RegistryAffordance ? ( + + + + ) : null + })()} {movingNode && movingNode.type !== 'building' && ( )} - {!movingNode && showBuildTool && tool === 'spawn' && ( - + {/* Registry-first: when the active tool's kind has a registered + NodeDefinition with a tool contribution, mount it here. */} + {!movingNode && useRegistryTool && RegistryToolComponent && ( + + + )} - {!movingNode && showBuildTool && tool === 'column' && ( + {!movingNode && !useRegistryTool && showBuildTool && tool === 'column' && ( )} - {!movingNode && showBuildTool && tool === 'elevator' && ( + {!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && ( )} - {!movingNode && - BuildToolComponent && - tool !== 'spawn' && - tool !== 'column' && - tool !== 'elevator' ? ( + {!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? ( ) : null} diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 352820c7..aaf05805 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -33,6 +33,7 @@ export const tools: ToolConfig[] = [ { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' }, + { id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' }, ] export function StructureTools() { diff --git a/packages/editor/src/components/ui/helpers/ceiling-helper.tsx b/packages/editor/src/components/ui/helpers/ceiling-helper.tsx deleted file mode 100644 index c1401546..00000000 --- a/packages/editor/src/components/ui/helpers/ceiling-helper.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ShortcutToken } from '../primitives/shortcut-token' - -export function CeilingHelper() { - return ( -
-
- - Add point -
-
- - Allow non-45° angles -
-
- - Cancel -
-
- ) -} diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 7998e427..d25aeafe 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -1,13 +1,12 @@ 'use client' +import { nodeRegistry } from '@pascal-app/core' import { useIsMobile } from '../../../hooks/use-mobile' import useEditor from '../../../store/use-editor' import { BuildingHelper } from './building-helper' -import { CeilingHelper } from './ceiling-helper' import { ItemHelper } from './item-helper' +import { RegisteredToolHelper } from './registered-tool-helper' import { RoofHelper } from './roof-helper' -import { SlabHelper } from './slab-helper' -import { WallHelper } from './wall-helper' export function HelperManager() { const mode = useEditor((s) => s.mode) @@ -27,19 +26,19 @@ export function HelperManager() { return null } - // Show appropriate helper based on current tool - switch (tool) { - case 'wall': - return - case 'item': - return - case 'slab': - return - case 'ceiling': - return - case 'roof': - return - default: - return null + // Registry-first: kinds with `def.toolHints` render through the generic + // `RegisteredToolHelper`. Today that covers ceiling / door / fence / + // item / shelf / slab / spawn / wall / window. + if (tool) { + const def = nodeRegistry.get(tool) + if (def?.toolHints && def.toolHints.length > 0) { + return + } } + + // 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 + return null } diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx new file mode 100644 index 00000000..cc48a3fb --- /dev/null +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -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 `` / `` / + * 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 ( +
+ {hints.map((hint) => ( +
+ + {hint.label} +
+ ))} +
+ ) +} diff --git a/packages/editor/src/components/ui/helpers/slab-helper.tsx b/packages/editor/src/components/ui/helpers/slab-helper.tsx deleted file mode 100644 index 238f5dde..00000000 --- a/packages/editor/src/components/ui/helpers/slab-helper.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ShortcutToken } from '../primitives/shortcut-token' - -export function SlabHelper() { - return ( -
-
- - Add point -
-
- - Allow non-45° angles -
-
- - Cancel -
-
- ) -} diff --git a/packages/editor/src/components/ui/helpers/wall-helper.tsx b/packages/editor/src/components/ui/helpers/wall-helper.tsx deleted file mode 100644 index 574d50d9..00000000 --- a/packages/editor/src/components/ui/helpers/wall-helper.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ShortcutToken } from '../primitives/shortcut-token' - -export function WallHelper() { - return ( -
-
- - Set wall start / end -
-
- - Allow non-45° angles -
-
- - Cancel -
-
- ) -} diff --git a/packages/editor/src/components/ui/panels/fence-panel.tsx b/packages/editor/src/components/ui/panels/fence-panel.tsx deleted file mode 100644 index f269cba5..00000000 --- a/packages/editor/src/components/ui/panels/fence-panel.tsx +++ /dev/null @@ -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) => { - 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 ( - - - handleUpdate({ style: value })} - options={FENCE_STYLE_OPTIONS} - value={node.style} - /> - handleUpdate({ baseStyle: value })} - options={FENCE_BASE_STYLE_OPTIONS} - value={node.baseStyle} - /> - handleUpdate({ showInfill: checked })} - /> - - - - - handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })} - precision={2} - step={0.1} - unit="m" - value={Math.round(curveOffset * 100) / 100} - /> - handleUpdate({ height: Math.max(0.4, value) })} - precision={2} - step={0.05} - unit="m" - value={node.height} - /> - handleUpdate({ thickness: Math.max(0.03, value) })} - precision={3} - step={0.005} - unit="m" - value={node.thickness} - /> - - - - handleUpdate({ baseHeight: Math.max(0.04, value) })} - precision={3} - step={0.01} - unit="m" - value={node.baseHeight} - /> - handleUpdate({ topRailHeight: Math.max(0.01, value) })} - precision={3} - step={0.005} - unit="m" - value={node.topRailHeight} - /> - handleUpdate({ postSpacing: Math.max(0.2, value) })} - precision={2} - step={0.05} - unit="m" - value={node.postSpacing} - /> - handleUpdate({ postSize: Math.max(0.01, value) })} - precision={3} - step={0.005} - unit="m" - value={node.postSize} - /> - handleUpdate({ groundClearance: Math.max(0, value) })} - precision={3} - step={0.005} - unit="m" - value={node.groundClearance} - /> - handleUpdate({ edgeInset: Math.max(0.005, value) })} - precision={3} - step={0.005} - unit="m" - value={node.edgeInset} - /> - - - ) -} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 7836b2e3..4fa82d2e 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -24,25 +24,12 @@ import { useCallback, useEffect, useState } from 'react' import { useIsMobile } from '../../../hooks/use-mobile' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' -import { CeilingPanel } from './ceiling-panel' -import { ColumnPanel } from './column-panel' -import { DoorPanel } from './door-panel' -import { ElevatorPanel } from './elevator-panel' -import { FencePanel } from './fence-panel' -import { ItemPanel } from './item-panel' import { MobilePanelSheet } from './mobile-panel-sheet' import { MobileSelectionBar } from './mobile-selection-bar' import { getNodeDisplay } from './node-display' import { PaintPanel } from './paint-panel' +import { ParametricInspector } from './parametric-inspector' import { ReferencePanel } from './reference-panel' -import { RoofPanel } from './roof-panel' -import { RoofSegmentPanel } from './roof-segment-panel' -import { SlabPanel } from './slab-panel' -import { SpawnPanel } from './spawn-panel' -import { StairPanel } from './stair-panel' -import { StairSegmentPanel } from './stair-segment-panel' -import { WallPanel } from './wall-panel' -import { WindowPanel } from './window-panel' type MovableNode = | ItemNode @@ -83,38 +70,15 @@ function isMovableNode(node: AnyNode | null): node is MovableNode { function panelForType(type: string | null) { if (!type) return null - switch (type) { - case 'item': - return - case 'roof': - return - case 'roof-segment': - return - case 'stair': - return - case 'stair-segment': - return - case 'slab': - return - case 'spawn': - return - case 'ceiling': - return - case 'column': - return - case 'wall': - return - case 'fence': - return - case 'door': - return - case 'elevator': - return - case 'window': - return - default: - return null - } + // Every kind now renders through ``, which either + // composes auto-derived editors from `parametrics.groups` or lazy- + // loads the kind-owned panel via `parametrics.customPanel`. The + // hardcoded switch is gone — all per-kind panel layout lives in + // `nodes/src//panel.tsx`. The `type` arg is preserved for + // future cases where we might want a non-registry fallback (e.g. + // reference scale, paint mode); leave the function shape intact. + void type + return } function MobilePanelLayer({ diff --git a/packages/editor/src/components/ui/panels/panel-wrapper.tsx b/packages/editor/src/components/ui/panels/panel-wrapper.tsx index 3f9aa19f..951a0127 100644 --- a/packages/editor/src/components/ui/panels/panel-wrapper.tsx +++ b/packages/editor/src/components/ui/panels/panel-wrapper.tsx @@ -7,7 +7,11 @@ import { cn } from '../../../lib/utils' interface PanelWrapperProps { title: string - icon?: string + /** Either a URL path (legacy panels pass `/icons/floor.png` etc., + * rendered via next/image) OR a React node (registry-driven + * inspector renders `` from + * `def.presentation.icon`). */ + icon?: string | React.ReactNode onClose?: () => void onReset?: () => void onBack?: () => void @@ -58,9 +62,18 @@ export function PanelWrapper({ )} - {icon && ( - - )} + {icon && + (typeof icon === 'string' ? ( + + ) : ( + {icon} + ))}

{title}

diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx new file mode 100644 index 00000000..fe7d5d17 --- /dev/null +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -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 + * `` per group, one control per field. Field kinds supported: + * - `number` → SliderControl with min/max/step/unit from the descriptor + * - `enum` → dark-themed ` onUpdate({ [key]: e.target.value } as Partial)} + value={str} + > + {field.options.map((opt) => ( + + ))} + +
+ ) + } + + case 'color': { + const str = typeof value === 'string' ? value : '#888888' + return ( +
+ {prettifyKey(key)} +
+ onUpdate({ [key]: e.target.value } as Partial)} + type="color" + value={str} + /> + onUpdate({ [key]: e.target.value } as Partial)} + type="text" + value={str} + /> +
+
+ ) + } + + 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 ( + { + const updated = [...v] as [number, number, number] + updated[index] = next + onUpdate({ [key]: updated } as Partial) + }} + 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 + + default: + // material / ref / unrecognized kinds — not implemented in v1. + return null + } +} + +function CustomFieldRenderer({ + Comp, + nodeId, + onUpdate, +}: { + Comp: ComponentType<{ node: AnyNode; onUpdate: (patch: Partial) => void }> + nodeId: AnyNodeId + onUpdate: (patch: Partial) => 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 +} + +// ─── 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(' ') +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx index b467f2c7..576e4874 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx @@ -130,8 +130,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const pi = polygon[i] + const pj = polygon[j] + if (!(pi && pj)) continue + area += pi[0] * pj[1] + area -= pj[0] * pi[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx index 08d6aa64..c6635e83 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx @@ -38,7 +38,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({ return ( } + actions={} depth={depth} expanded={false} hasChildren={false} @@ -53,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx new file mode 100644 index 00000000..d639fe2f --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx @@ -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 ( + } + depth={depth} + expanded={expanded} + hasChildren={hasChildren} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={handleDoubleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + onToggle={handleToggle} + > + {hasChildren && + children.map((childId, index) => ( + + ))} + + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx index 4ce5dd4e..d26854cd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx @@ -91,8 +91,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const pi = polygon[i] + const pj = polygon[j] + if (!(pi && pj)) continue + area += pi[0] * pj[1] + area -= pj[0] * pi[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 2a310749..462d2d4e 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -63,6 +63,7 @@ import { FenceTreeNode } from './fence-tree-node' import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' import { RoofTreeNode } from './roof-tree-node' +import { ShelfTreeNode } from './shelf-tree-node' import { SlabTreeNode } from './slab-tree-node' import { SpawnTreeNode } from './spawn-tree-node' import { StairTreeNode } from './stair-tree-node' @@ -76,47 +77,61 @@ interface TreeNodeProps { isLast?: boolean } +// Per-kind tree-node components keyed by `node.type`. Lookup replaces +// the legacy switch — adding a kind to this map is now the only edit +// needed in this file (the switch's `case '':` clauses were +// flagged by the Phase 6 grep gate as the last per-kind dispatch +// outside the registry; future work moves these to a +// `def.presentation`-driven generic tree-node and removes this map +// entirely). +const treeNodeByType: Record< + string, + React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }> +> = { + building: BuildingTreeNode as React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId + }>, + ceiling: CeilingTreeNode, + column: ColumnTreeNode, + elevator: ElevatorTreeNode, + level: LevelTreeNode as React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId + }>, + shelf: ShelfTreeNode as React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId + }>, + slab: SlabTreeNode, + spawn: SpawnTreeNode as React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId + }>, + wall: WallTreeNode, + fence: FenceTreeNode, + roof: RoofTreeNode, + stair: StairTreeNode, + door: DoorTreeNode, + window: WindowTreeNode, + zone: ZoneTreeNode as React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId + }>, + item: ItemTreeNode, +} + export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { const nodeType = useScene((state) => state.nodes[nodeId]?.type) - if (!nodeType) return null - - switch (nodeType) { - case 'building': - return ( - - ) - case 'ceiling': - return - case 'column': - return - case 'elevator': - return - case 'level': - return - case 'slab': - return - case 'spawn': - return - case 'wall': - return - case 'fence': - return - case 'roof': - return - case 'stair': - return - case 'item': - return - case 'door': - return - case 'window': - return - case 'zone': - return - default: - return null - } + const Component = treeNodeByType[nodeType] + if (!Component) return null + return }) interface TreeNodeWrapperProps { diff --git a/packages/editor/src/components/viewer-zone-system.tsx b/packages/editor/src/components/viewer-zone-system.tsx index bbc26132..015e2887 100644 --- a/packages/editor/src/components/viewer-zone-system.tsx +++ b/packages/editor/src/components/viewer-zone-system.tsx @@ -12,7 +12,7 @@ export const ViewerZoneSystem = () => { const structureLayer = useEditor.getState().structureLayer const nodes = useScene.getState().nodes - sceneRegistry.byType.zone.forEach((id) => { + sceneRegistry.byType.zone!.forEach((id) => { const obj = sceneRegistry.nodes.get(id) if (!obj) return diff --git a/packages/editor/src/hooks/use-drag-action.ts b/packages/editor/src/hooks/use-drag-action.ts new file mode 100644 index 00000000..97674f79 --- /dev/null +++ b/packages/editor/src/hooks/use-drag-action.ts @@ -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 | undefined + return { + shift: ne?.shiftKey ?? false, + alt: ne?.altKey ?? false, + ctrl: ne?.ctrlKey ?? false, + meta: ne?.metaKey ?? false, + } +} + +export type UseDragActionArgs = { + /** When true the session is live: subscribes to grid events + Esc. + * Flipping to false (or unmount) cancels and cleans up. */ + active: boolean + action: DragAction + /** 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(args: UseDragActionArgs) { + // 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(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 } diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index baffb3c7..44c7267a 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -4,12 +4,109 @@ export { type SnapshotCameraData, ThumbnailGenerator, } from './components/editor/thumbnail-generator' +// SVG path builders for arc / annular-sector / arrow-head shapes — +// inlined into `kind: 'path'` / `kind: 'polygon'` primitives by curved +// stair rendering in `nodes/src/stair/floorplan.ts`. +export { + buildSvgAnnularSectorPath, + buildSvgArcPath, + buildSvgArrowHeadPoints, + getArcPlanPoint, +} from './components/editor-2d/svg-paths' +// Phase 5 Stage D transitional exports — pure drafting / angle helpers +// consumed by kind-owned drag actions in @pascal-app/nodes. Stage F +// cleanup moves these into @pascal-app/nodes (fence/drafting.ts + +// shared/segment-angle.ts) once every Stage D port is in. +export { + createFenceOnCurrentLevel, + type FencePlanPoint, + snapFenceDraftPoint, +} from './components/tools/fence/fence-drafting' +// Placement-math helpers — shared by kind-owned placement tools in +// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement, +// item drop) so kinds don't reach into editor internals. +export { + calculateCursorRotation, + calculateItemRotation, + getSideFromNormal, + isValidWallSideFace, + snapToGrid, + snapToHalf, + snapUpToGridStep, + stripTransient, +} from './components/tools/item/placement-math' +export type { PlacementState } from './components/tools/item/placement-types' +// Item placement / move primitives. Re-exported here so the registry-driven +// item move-tool in `@pascal-app/nodes` can compose them — same hooks the +// legacy `MoveItemContent` + `ItemTool` use. Once item placement is fully +// owned by `nodes`, these can be inlined there and dropped from editor. +export { type DraftNodeHandle, useDraftNode } from './components/tools/item/use-draft-node' +export { + type PlacementCoordinatorConfig, + usePlacementCoordinator, +} from './components/tools/item/use-placement-coordinator' +export { CursorSphere } from './components/tools/shared/cursor-sphere' +// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. +export { + PolygonEditor, + type PolygonEditorProps, +} from './components/tools/shared/polygon-editor' +export { + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from './components/tools/shared/segment-angle' +// Stair placement defaults — used by the kind-owned stair / stair-segment +// panels. Re-exported from `components/tools/stair/stair-defaults.ts`. +export { + DEFAULT_CURVED_STAIR_INNER_RADIUS, + DEFAULT_CURVED_STAIR_SWEEP_ANGLE, + DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, + DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, + DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, + DEFAULT_SPIRAL_TOP_LANDING_DEPTH, + DEFAULT_SPIRAL_TOP_LANDING_MODE, + DEFAULT_STAIR_ATTACHMENT_SIDE, + DEFAULT_STAIR_FILL_TO_FLOOR, + DEFAULT_STAIR_HEIGHT, + DEFAULT_STAIR_LENGTH, + DEFAULT_STAIR_RAILING_HEIGHT, + DEFAULT_STAIR_RAILING_MODE, + DEFAULT_STAIR_STEP_COUNT, + DEFAULT_STAIR_THICKNESS, + DEFAULT_STAIR_TYPE, + DEFAULT_STAIR_WIDTH, +} from './components/tools/stair/stair-defaults' +export { + createWallOnCurrentLevel, + getWallGridStep, + isWallLongEnough, + snapPointToGrid, + snapScalarToGrid, + snapWallDraftPoint, + type WallPlanPoint, +} from './components/tools/wall/wall-drafting' export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions' export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' export { useCommandPalette } from './components/ui/command-palette' +export { ActionButton, ActionGroup } from './components/ui/controls/action-button' +export { MaterialPicker } from './components/ui/controls/material-picker' +export { MetricControl } from './components/ui/controls/metric-control' +export { PanelSection } from './components/ui/controls/panel-section' +export { SegmentedControl } from './components/ui/controls/segmented-control' export { SliderControl } from './components/ui/controls/slider-control' +export { ToggleControl } from './components/ui/controls/toggle-control' export { FloatingLevelSelector } from './components/ui/floating-level-selector' export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items' +// Item collections UI — used by the kind-owned ItemPanel in nodes/. +export { CollectionsPopover } from './components/ui/panels/collections/collections-popover' +// Phase 5 Stage E — kinds with bespoke editors (slab holes list, +// ceiling height presets, etc.) use `parametrics.customPanel` to mount +// a kind-owned panel and need PanelWrapper for the chrome. +export { PanelWrapper } from './components/ui/panels/panel-wrapper' +// Presets popover — used by kind-owned door / window panels for their +// hardware / type / opening presets. +export { PresetsPopover } from './components/ui/panels/presets/presets-popover' export { PALETTE_COLORS } from './components/ui/primitives/color-dot' export { useSidebarStore } from './components/ui/primitives/sidebar' export { Slider } from './components/ui/primitives/slider' @@ -24,14 +121,62 @@ export { export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel' export type { SidebarTab } from './components/ui/sidebar/tab-bar' export type { PresetsAdapter, PresetsTab } from './contexts/presets-context' -export { PresetsProvider } from './contexts/presets-context' +export { PresetsProvider, usePresetsAdapter } from './contexts/presets-context' export type { SaveStatus } from './hooks/use-auto-save' +// useDragAction is the React-side glue for the registry's DragAction +// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports) +// can express their affordances declaratively in their own folder. +export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action' +// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). +export { markToolCancelConsumed } from './hooks/use-keyboard' +export { EDITOR_LAYER } from './lib/constants' +// Helper libs used by the kind-owned roof / stair / elevator panels. +export { + resolveCurrentBuildingId, + resolveElevatorNodeSupportY, + resolveElevatorSupportLevelId, + resolveElevatorSupportY, +} from './lib/elevator-support' +// Floor-plan stair helpers — the cumulative-transform walk +// (`computeFloorplanStairSegmentTransforms`) and the rich segment-entry +// builder (`buildFloorplanStairEntry`) used by the kind-owned stair +// floor-plan emitter in `@pascal-app/nodes/src/stair/floorplan.ts`. +// Each flight's transform depends on every prior sibling's length / +// height / `attachmentSide`, so individual stair-segments can't compute +// their own polygon in isolation — the stair (parent) owns the +// computation and emits the whole stack as one registry entry. +export { + buildFloorplanStairEntry, + type FloorplanStairArrowEntry, + type FloorplanStairEntry, + type FloorplanStairSegmentEntry, +} from './lib/floorplan' +export { + buildRoofSurfaceMaterialPatch, + buildSingleSurfaceMaterialPatch, + buildStairSurfaceMaterialPatch, + buildWallSurfaceMaterialPatch, + getActivePaintMaterialLabel, + hasActivePaintMaterial, +} from './lib/material-paint' +export { duplicateRoofSubtree } from './lib/roof-duplication' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' +export { duplicateStairSubtree } from './lib/stair-duplication' +// `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ +// nodes` so they don't need their own copy / their own tailwind-merge +// dependency. +export { cn } from './lib/utils' export { default as useAudio } from './store/use-audio' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' -export type { FloorplanSelectionTool, SplitOrientation, ViewMode } from './store/use-editor' +export type { + FloorplanSelectionTool, + MovingFenceEndpoint, + MovingWallEndpoint, + SplitOrientation, + ViewMode, +} from './store/use-editor' export { default as useEditor } from './store/use-editor' export { type PaletteView, diff --git a/packages/editor/src/lib/level-duplication.ts b/packages/editor/src/lib/level-duplication.ts index 747767b4..cd08bcff 100644 --- a/packages/editor/src/lib/level-duplication.ts +++ b/packages/editor/src/lib/level-duplication.ts @@ -33,50 +33,62 @@ function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) { 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 '':` + * 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> = { + wall: [ + 'material', + 'materialPreset', + 'interiorMaterial', + 'interiorMaterialPreset', + 'exteriorMaterial', + 'exteriorMaterialPreset', + ], + slab: ['material', 'materialPreset'], + ceiling: ['material', 'materialPreset'], + fence: ['material', 'materialPreset'], + shelf: ['material', 'materialPreset'], + 'roof-segment': ['material', 'materialPreset'], + 'stair-segment': ['material', 'materialPreset'], + window: ['material', 'materialPreset'], + door: ['material', 'materialPreset'], + roof: [ + 'material', + 'materialPreset', + 'topMaterial', + 'topMaterialPreset', + 'edgeMaterial', + 'edgeMaterialPreset', + 'wallMaterial', + 'wallMaterialPreset', + ], + stair: [ + 'material', + 'materialPreset', + 'railingMaterial', + 'railingMaterialPreset', + 'treadMaterial', + 'treadMaterialPreset', + 'sideMaterial', + 'sideMaterialPreset', + ], +} + function stripMaterials(node: AnyNode): AnyNode { + const fields = MATERIAL_FIELDS_BY_KIND[node.type] + if (!fields) return node const next = { ...node } as Record - - 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 - } - + for (const field of fields) delete next[field] return next as AnyNode } diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 2aef4091..7c99f241 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -13,6 +13,7 @@ import { type MaterialTarget, type RoofNode, type RoofSurfaceMaterialRole, + type ShelfNode, type SlabNode, type StairNode, type StairSurfaceMaterialRole, @@ -22,7 +23,7 @@ import { export type PaintableMaterialTarget = Extract< MaterialTarget, - 'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' + 'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' | 'shelf' > export type SingleSurfaceMaterialRole = 'surface' @@ -133,7 +134,7 @@ export function buildStairSurfaceMaterialPatch( } export function buildSingleSurfaceMaterialPatch< - TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode, + TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode, >(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial { return { material, @@ -222,7 +223,8 @@ export function resolveActivePaintMaterialFromSelection(params: { (selectedNode.type === 'fence' || selectedNode.type === 'column' || selectedNode.type === 'slab' || - selectedNode.type === 'ceiling') && + selectedNode.type === 'ceiling' || + selectedNode.type === 'shelf') && selectedMaterialTarget.role === 'surface' ) { const target = selectedNode.type @@ -280,5 +282,9 @@ export function resolvePaintTargetFromSelection(params: { return 'ceiling' } + if (selectedNode.type === 'shelf') { + return 'shelf' + } + return null } diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 6eea48f3..4304241d 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -283,7 +283,11 @@ export function syncEditorSelectionFromCurrentScene() { if (shouldRestoreEditorUiState) { if (restoredSelection) { - useViewer.getState().setSelection(restoredSelection) + // PersistedSelectionPath carries plain `string` ids (read from + // localStorage, no branded-template-literal guarantee). The viewer's + // SelectionPath expects branded ids. The runtime values match the + // brand; the cast bridges the static gap. + useViewer.getState().setSelection(restoredSelection as never) useEditor.setState( restoredEditorUiState.phase === 'site' ? (selectionDrivenEditorUiState ?? restoredEditorUiState) @@ -305,7 +309,7 @@ export function syncEditorSelectionFromCurrentScene() { } if (restoredSelection) { - useViewer.getState().setSelection(restoredSelection) + useViewer.getState().setSelection(restoredSelection as never) if (selectionDrivenEditorUiState) { useEditor.setState(selectionDrivenEditorUiState) } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 03d963c0..057cbf66 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -65,6 +65,7 @@ export type StructureTool = | 'spawn' | 'window' | 'door' + | 'shelf' // Furnish mode tools (items and decoration) export type FurnishTool = 'item' diff --git a/packages/nodes/package.json b/packages/nodes/package.json new file mode 100644 index 00000000..72e718c8 --- /dev/null +++ b/packages/nodes/package.json @@ -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" +} diff --git a/packages/nodes/src/building/definition.ts b/packages/nodes/src/building/definition.ts new file mode 100644 index 00000000..803d46d6 --- /dev/null +++ b/packages/nodes/src/building/definition.ts @@ -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 = { + 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.', + }, +} diff --git a/packages/nodes/src/building/index.ts b/packages/nodes/src/building/index.ts new file mode 100644 index 00000000..6d9bb2e7 --- /dev/null +++ b/packages/nodes/src/building/index.ts @@ -0,0 +1 @@ +export { buildingDefinition } from './definition' diff --git a/packages/nodes/src/building/parametrics.ts b/packages/nodes/src/building/parametrics.ts new file mode 100644 index 00000000..8a01866f --- /dev/null +++ b/packages/nodes/src/building/parametrics.ts @@ -0,0 +1,5 @@ +import type { BuildingNode, ParametricDescriptor } from '@pascal-app/core' + +export const buildingParametrics: ParametricDescriptor = { + groups: [], +} diff --git a/packages/viewer/src/components/renderers/building/building-renderer.tsx b/packages/nodes/src/building/renderer.tsx similarity index 84% rename from packages/viewer/src/components/renderers/building/building-renderer.tsx rename to packages/nodes/src/building/renderer.tsx index 020f6a7f..87f2f2f4 100644 --- a/packages/viewer/src/components/renderers/building/building-renderer.tsx +++ b/packages/nodes/src/building/renderer.tsx @@ -1,8 +1,9 @@ +'use client' + import { type BuildingNode, useRegistry } from '@pascal-app/core' +import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer' import { useRef } from 'react' import type { Group } from 'three' -import { useNodeEvents } from '../../../hooks/use-node-events' -import { NodeRenderer } from '../node-renderer' export const BuildingRenderer = ({ node }: { node: BuildingNode }) => { const ref = useRef(null!) @@ -23,3 +24,5 @@ export const BuildingRenderer = ({ node }: { node: BuildingNode }) => { ) } + +export default BuildingRenderer diff --git a/packages/nodes/src/building/schema.ts b/packages/nodes/src/building/schema.ts new file mode 100644 index 00000000..b7ed33ec --- /dev/null +++ b/packages/nodes/src/building/schema.ts @@ -0,0 +1 @@ +export { BuildingNode } from '@pascal-app/core' diff --git a/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx similarity index 57% rename from packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx rename to packages/nodes/src/ceiling/boundary-editor.tsx index 9b4e7f62..b807a6a8 100644 --- a/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -1,27 +1,29 @@ +'use client' + import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core' +import { PolygonEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback } from 'react' -import { PolygonEditor } from '../shared/polygon-editor' - -interface CeilingBoundaryEditorProps { - ceilingId: CeilingNode['id'] -} /** - * Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling - * Uses the generic PolygonEditor component + * Phase 5 Stage D — ceiling boundary editor (registry-driven). + * + * Thin wrapper around the shared `` (same shape as + * slab's boundary-editor). Activates when a ceiling is selected in + * structure/select mode and no hole edit is in progress. */ -export const CeilingBoundaryEditor: React.FC = ({ ceilingId }) => { - const ceilingNode = useScene((state) => state.nodes[ceilingId]) - const updateNode = useScene((state) => state.updateNode) - const setSelection = useViewer((state) => state.setSelection) +export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = ({ + ceilingId, +}) => { + const ceilingNode = useScene((s) => s.nodes[ceilingId]) + const updateNode = useScene((s) => s.updateNode) + const setSelection = useViewer((s) => s.setSelection) const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null const handlePolygonChange = useCallback( (newPolygon: Array<[number, number]>) => { updateNode(ceilingId, { polygon: newPolygon }) - // Re-assert selection so the ceiling stays selected after the edit setSelection({ selectedIds: [ceilingId] }) }, [ceilingId, updateNode, setSelection], @@ -41,3 +43,5 @@ export const CeilingBoundaryEditor: React.FC = ({ ce /> ) } + +export default CeilingBoundaryEditor diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts new file mode 100644 index 00000000..d1863763 --- /dev/null +++ b/packages/nodes/src/ceiling/definition.ts @@ -0,0 +1,115 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildCeilingFloorplan } from './floorplan' +import { + ceilingAddVertexAffordance, + ceilingMoveEdgeAffordance, + ceilingMoveVertexAffordance, +} from './floorplan-affordances' +import { ceilingFloorplanMoveTarget } from './floorplan-move' +import { ceilingParametrics } from './parametrics' +import { CeilingNode } from './schema' + +/** + * Ceiling — Phase 5 batch kind, polygon-based. Structurally similar to + * slab but with React-rendered hosted children + TSL shader materials + + * named meshes that other systems poke (`getObjectByName('ceiling-grid')`). + * + * **Stage B intentionally skipped**: pure `def.geometry` extraction + * would lose the React children rendering (hosted items) and the + * named-mesh structure. Ceiling keeps `def.renderer` as the custom + * escape hatch (per plans/editor-node-registry.md "custom-behavior + * escape hatch"). Renderer wraps the legacy CeilingRenderer; system + * wraps the legacy CeilingSystem. + * + * **Stage C completed**: `def.floorplan` builder draws the ceiling + * polygon as a dashed outline in floor plan; legacy `ceilingPolygons` + * short-circuits to [] when ceiling is registered. + */ +export const ceilingDefinition: NodeDefinition = { + kind: 'ceiling', + schemaVersion: 1, + schema: CeilingNode, + category: 'structure', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + polygon: [], + holes: [], + holeMetadata: [], + height: 2.5, + autoFromWalls: false, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + surfaces: { + top: { height: (n) => (n as CeilingNode).height }, + }, + duplicable: true, + deletable: true, + }, + + relations: { + hosts: ['item'], + cascadeDelete: 'descendants', + }, + + parametrics: ceilingParametrics, + + // Stage D: kind-owned placement tool. Multi-click polygon drawing + // with a vertical TSL-gradient connector + ground-shadow lines. + tool: () => import('./tool'), + + // Stage D — all four ceiling drag-affordances live in this folder. + // 1:1 port of the legacy tools (scene.update per tick + history + // dance + preview fill/outline overlay on move). + affordanceTools: { + 'boundary-edit': () => import('./boundary-editor'), + 'hole-edit': () => import('./hole-editor'), + move: () => import('./move-tool'), + }, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + system: { + module: () => import('./system'), + priority: 4, + }, + floorplan: buildCeilingFloorplan, + // 2D move handler — translates polygon by cursor delta from first + // pointer position. Mirror of slab; 3D `MoveCeilingTool` skips + // 2D-sourced grid events so they don't double-write on commit. + floorplanMoveTarget: ceilingFloorplanMoveTarget, + // Sister to `affordanceTools['boundary-edit']`. Same `polygon` field; + // SVG vertex handles dispatch to this affordance via the floor-plan + // registry layer. + floorplanAffordances: { + 'move-vertex': ceilingMoveVertexAffordance, + 'add-vertex': ceilingAddVertexAffordance, + 'move-edge': ceilingMoveEdgeAffordance, + }, + + toolHints: [ + { key: 'Left click', label: 'Trace ceiling outline' }, + { key: 'Enter', label: 'Finish ceiling' }, + { key: 'Esc', label: 'Cancel' }, + ], + + presentation: { + label: 'Ceiling', + description: 'A polygon-bounded ceiling surface that hosts ceiling-mounted items.', + icon: { kind: 'url', src: '/icons/ceiling.png' }, + paletteSection: 'structure', + paletteOrder: 40, + }, + + mcp: { + description: 'A polygon-bounded ceiling with optional cutout holes.', + }, +} diff --git a/packages/nodes/src/ceiling/floorplan-affordances.ts b/packages/nodes/src/ceiling/floorplan-affordances.ts new file mode 100644 index 00000000..d280d2e7 --- /dev/null +++ b/packages/nodes/src/ceiling/floorplan-affordances.ts @@ -0,0 +1,16 @@ +import type { CeilingNode } from '@pascal-app/core' +import { + createPolygonAddVertexAffordance, + createPolygonMoveEdgeAffordance, + createPolygonVertexAffordance, +} from '../shared/polygon-vertex-affordance' + +/** + * 2D drag affordances for ceiling. Same three operations as slab + * (`move-vertex`, `add-vertex`, `move-edge`), each accepting an + * optional `holeIndex`. See `slab/floorplan-affordances.ts` for the + * full contract. + */ +export const ceilingMoveVertexAffordance = createPolygonVertexAffordance('ceiling') +export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance('ceiling') +export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance('ceiling') diff --git a/packages/nodes/src/ceiling/floorplan-move.ts b/packages/nodes/src/ceiling/floorplan-move.ts new file mode 100644 index 00000000..2bf473fe --- /dev/null +++ b/packages/nodes/src/ceiling/floorplan-move.ts @@ -0,0 +1,88 @@ +import { + type AnyNodeId, + type CeilingNode, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + sceneRegistry, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' +import type * as THREE from 'three' + +/** + * 2D floor-plan move handler for ceiling — mirrors the 3D `MoveCeilingTool` + * live-drag pattern. See the equivalent module in `slab/floorplan-move.ts` + * for the full rationale; the only ceiling-specific detail is the + * preserved Y offset (`CeilingSystem` positions the mesh at `height − 0.01` + * on rebuild, so the direct `mesh.position.y` mirrors that to avoid a + * vertical teleport when the React group position is reconciled). + */ +const GRID_STEP = 0.5 + +function translatePolygon( + polygon: ReadonlyArray, + dx: number, + dz: number, +): Array<[number, number]> { + return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]) +} + +export const ceilingFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + const ceilingId = node.id as AnyNodeId + const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number]) + const originalHoles = (node.holes ?? []).map((hole) => + hole.map(([x, z]) => [x, z] as [number, number]), + ) + const height = node.height ?? 2.5 + let anchor: [number, number] | null = null + let lastDelta: [number, number] = [0, 0] + + const session: FloorplanMoveTargetSession = { + affectedIds: [ceilingId], + apply({ planPoint, modifiers }) { + const snapped: WallPlanPoint = modifiers.shiftKey + ? ([planPoint[0], planPoint[1]] as WallPlanPoint) + : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + if (!anchor) { + anchor = [snapped[0], snapped[1]] + return + } + const dx = snapped[0] - anchor[0] + const dz = snapped[1] - anchor[1] + lastDelta = [dx, dz] + useLiveTransforms.getState().set(ceilingId, { + position: [dx, 0, dz], + rotation: 0, + }) + const mesh = sceneRegistry.nodes.get(ceilingId) as THREE.Object3D | undefined + // Preserve ceiling height — `CeilingSystem` sets `mesh.position.y = + // height − 0.01` on each rebuild; mirror that during the drag so + // the mesh stays at ceiling height (not collapsed to y=0). + if (mesh) mesh.position.set(dx, height - 0.01, dz) + }, + canCommit() { + const live = useScene.getState().nodes[ceilingId] as CeilingNode | undefined + if (!live || live.type !== 'ceiling') return false + const [dx, dz] = lastDelta + if (dx === 0 && dz === 0) return false + // Sync commit sequence — see `slab/floorplan-move.ts` for the + // full ordering rationale (scene write → direct markDirty → + // useLiveTransforms.clear, all sync in this handler so React + // render + CeilingSystem rebuild land in the same paint). + useScene.getState().updateNodes([ + { + id: ceilingId, + data: { + polygon: translatePolygon(originalPolygon, dx, dz), + holes: originalHoles.map((h) => translatePolygon(h, dx, dz)), + }, + }, + ]) + useScene.getState().markDirty(ceilingId) + useLiveTransforms.getState().clear(ceilingId) + return true + }, + } + return session +} diff --git a/packages/nodes/src/ceiling/floorplan.ts b/packages/nodes/src/ceiling/floorplan.ts new file mode 100644 index 00000000..d7224db6 --- /dev/null +++ b/packages/nodes/src/ceiling/floorplan.ts @@ -0,0 +1,109 @@ +import type { + CeilingNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, +} from '@pascal-app/core' + +/** + * Stage C floor-plan builder for ceiling. Dashed boundary (ceilings sit + * above the slab); when selected, mounts the same boundary editor as + * slab — vertex + midpoint + edge handles on the outer ring AND every + * hole, with `holeIndex` carried in the handle payloads. + */ +export function buildCeilingFloorplan( + node: CeilingNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const polygon = node.polygon + if (!polygon || polygon.length < 3) return null + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const showSelectedChrome = isSelected || isHighlighted + + const outer: FloorplanPoint[] = polygon.map(([x, z]) => [x, z] as FloorplanPoint) + + const ring = (points: FloorplanPoint[]) => { + const [first, ...rest] = points + if (!first) return '' + return [`M ${first[0]} ${first[1]}`, ...rest.map(([x, y]) => `L ${x} ${y}`), 'Z'].join(' ') + } + + const segments: string[] = [ring(outer)] + const holes = node.holes ?? [] + for (const hole of holes) { + if (hole.length < 3) continue + segments.push(ring(hole.map(([x, z]) => [x, z] as FloorplanPoint))) + } + + const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#94a3b8' + + const children: FloorplanGeometry[] = [ + { + kind: 'path', + d: segments.join(' '), + fill: 'none', + stroke, + strokeWidth: showSelectedChrome ? 0.04 : 0.03, + strokeDasharray: '0.15 0.1', + opacity: showSelectedChrome ? 0.95 : 0.7, + }, + ] + + if (isSelected) { + appendRingEditor(children, polygon, undefined) + holes.forEach((hole, holeIndex) => { + if (hole.length >= 3) appendRingEditor(children, hole, holeIndex) + }) + } + + return { kind: 'group', children } +} + +/** + * Same boundary editor as slab — see `nodes/src/slab/floorplan.ts` for + * the contract. The kinds differ only in their fill / stroke chrome; + * the editor primitives are identical. + */ +function appendRingEditor( + children: FloorplanGeometry[], + ring: ReadonlyArray, + holeIndex: number | undefined, +): void { + for (let i = 0; i < ring.length; i++) { + const a = ring[i]! + const b = ring[(i + 1) % ring.length]! + children.push({ + kind: 'edge-handle', + x1: a[0], + y1: a[1], + x2: b[0], + y2: b[1], + affordance: 'move-edge', + payload: { holeIndex, edgeIndex: i }, + }) + } + for (let i = 0; i < ring.length; i++) { + const a = ring[i]! + const b = ring[(i + 1) % ring.length]! + children.push({ + kind: 'midpoint-handle', + point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], + affordance: 'add-vertex', + payload: { holeIndex, edgeIndex: i }, + }) + } + for (let i = 0; i < ring.length; i++) { + const [x, z] = ring[i]! + children.push({ + kind: 'endpoint-handle', + point: [x, z], + state: 'idle', + affordance: 'move-vertex', + payload: { holeIndex, vertexIndex: i }, + }) + } +} diff --git a/packages/editor/src/components/tools/ceiling/ceiling-hole-editor.tsx b/packages/nodes/src/ceiling/hole-editor.tsx similarity index 64% rename from packages/editor/src/components/tools/ceiling/ceiling-hole-editor.tsx rename to packages/nodes/src/ceiling/hole-editor.tsx index c11495d2..5923a5ea 100644 --- a/packages/editor/src/components/tools/ceiling/ceiling-hole-editor.tsx +++ b/packages/nodes/src/ceiling/hole-editor.tsx @@ -1,21 +1,20 @@ +'use client' + import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core' +import { PolygonEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback } from 'react' -import { PolygonEditor } from '../shared/polygon-editor' - -interface CeilingHoleEditorProps { - ceilingId: CeilingNode['id'] - holeIndex: number -} /** - * Ceiling hole editor - allows editing a specific hole polygon within a ceiling - * Uses the generic PolygonEditor component + * Phase 5 Stage D — ceiling hole editor (registry-driven). */ -export const CeilingHoleEditor: React.FC = ({ ceilingId, holeIndex }) => { - const ceilingNode = useScene((state) => state.nodes[ceilingId]) - const updateNode = useScene((state) => state.updateNode) - const setSelection = useViewer((state) => state.setSelection) +export const CeilingHoleEditor: React.FC<{ + ceilingId: CeilingNode['id'] + holeIndex: number +}> = ({ ceilingId, holeIndex }) => { + const ceilingNode = useScene((s) => s.nodes[ceilingId]) + const updateNode = useScene((s) => s.updateNode) + const setSelection = useViewer((s) => s.setSelection) const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null const holes = ceiling?.holes || [] @@ -26,7 +25,6 @@ export const CeilingHoleEditor: React.FC = ({ ceilingId, const updatedHoles = [...holes] updatedHoles[holeIndex] = newPolygon updateNode(ceilingId, { holes: updatedHoles }) - // Re-assert selection so the ceiling stays selected after the edit setSelection({ selectedIds: [ceilingId] }) }, [ceilingId, holeIndex, holes, updateNode, setSelection], @@ -39,7 +37,7 @@ export const CeilingHoleEditor: React.FC = ({ ceilingId, allowEdgeMove allowPolygonMove color="#ef4444" - levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes + levelId={resolveLevelId(ceiling, useScene.getState().nodes)} minVertices={3} onPolygonChange={handlePolygonChange} polygon={hole} @@ -47,3 +45,5 @@ export const CeilingHoleEditor: React.FC = ({ ceilingId, /> ) } + +export default CeilingHoleEditor diff --git a/packages/nodes/src/ceiling/index.ts b/packages/nodes/src/ceiling/index.ts new file mode 100644 index 00000000..1c0a3575 --- /dev/null +++ b/packages/nodes/src/ceiling/index.ts @@ -0,0 +1,2 @@ +export { ceilingDefinition } from './definition' +export { CeilingNode } from './schema' diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx new file mode 100644 index 00000000..5dc30346 --- /dev/null +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -0,0 +1,322 @@ +'use client' + +import { + type AnyNodeId, + type CeilingNode, + emitter, + type GridEvent, + sceneRegistry, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type * as THREE from 'three' +import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three' + +/** + * Phase 5 Stage D — ceiling whole-move tool. + * + * Live-drag pattern: translate the ceiling MESH visually via + * `sceneRegistry.nodes.get(id).position` + a mirror in + * `useLiveTransforms`. No `scene.update` during the drag — polygon CSG + * with holes isn't rebuilt per tick. On commit we write the translated + * polygon to the scene once; the legacy `CeilingSystem` resets the + * mesh's X/Z position on rebuild (`mesh.position.x = 0`, + * `mesh.position.z = 0`) so the visual transitions smoothly. + * + * 0.5m grid snap (matches legacy). + */ +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] +} + +function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number, height: number): void { + const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined + // CeilingSystem positions the mesh at height−0.01 on rebuild; we + // preserve the Y while offsetting X/Z during the drag. + if (mesh) mesh.position.set(deltaX, height - 0.01, deltaZ) +} + +/** + * Distinguish 3D-canvas grid events (this tool) from 2D floor-plan + * grid events (`ceilingFloorplanMoveTarget` + `FloorplanRegistryMoveOverlay` + * Path 1). See the equivalent helper in `slab/move-tool.tsx` for the + * full rationale. + */ +function isFloorplanSourcedEvent(event: GridEvent): boolean { + const native: unknown = event.nativeEvent + const candidate = + (native as { target?: unknown; nativeEvent?: { target?: unknown } } | null) ?? null + const target = + (candidate?.target as Element | null | undefined) ?? + (candidate?.nativeEvent as { target?: Element | null } | undefined)?.target ?? + null + if (!target || typeof (target as Element).closest !== 'function') return false + return (target as Element).closest('[data-floorplan-scene]') != null +} + +export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { + const activatedAtRef = useRef(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 originalCenterRef = useRef(getPolygonCenter(originalPolygonRef.current)) + const heightRef = useRef(node.height ?? 2.5) + const dragAnchorRef = useRef<[number, number] | null>(null) + const previousGridPosRef = useRef<[number, number] | null>(null) + const deltaRef = useRef<[number, number]>([0, 0]) + + const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { + const c = originalCenterRef.current + return [c[0], heightRef.current, c[1]] + }) + + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) + }, []) + + useEffect(() => { + const originalPolygon = originalPolygonRef.current + const originalHoles = originalHolesRef.current + const originalCenter = originalCenterRef.current + const height = heightRef.current + const ceilingId = node.id + + let wasCommitted = false + + const applyPreview = (deltaX: number, deltaZ: number) => { + deltaRef.current = [deltaX, deltaZ] + setMeshOffset(ceilingId as AnyNodeId, deltaX, deltaZ, height) + // Aligned with slab/fence: the delta matches the direct mesh + // mutation. CeilingRenderer doesn't bind position via React, so + // this entry isn't consumed for rendering, but kept consistent + // in case other systems read it. + useLiveTransforms.getState().set(ceilingId, { + position: [deltaX, 0, deltaZ], + rotation: 0, + }) + setCursorLocalPos([originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ]) + } + + const clearPreview = () => { + const mesh = sceneRegistry.nodes.get(ceilingId as AnyNodeId) as THREE.Object3D | undefined + if (mesh) { + mesh.position.x = 0 + mesh.position.z = 0 + // Leave Y at whatever the CeilingSystem set it to. + } + useLiveTransforms.getState().clear(ceilingId) + } + + const onGridMove = (event: GridEvent) => { + if (isFloorplanSourcedEvent(event)) return + const localX = snap(event.localPosition[0]) + const localZ = snap(event.localPosition[2]) + + if ( + previousGridPosRef.current && + (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) + ) { + triggerSFX('sfx:grid-snap') + } + previousGridPosRef.current = [localX, localZ] + + const anchor = dragAnchorRef.current ?? [localX, localZ] + dragAnchorRef.current = anchor + + applyPreview(localX - anchor[0], localZ - anchor[1]) + } + + const onGridClick = (event: GridEvent) => { + if (isFloorplanSourcedEvent(event)) return + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + + const [deltaX, deltaZ] = deltaRef.current + wasCommitted = true + + if (deltaX !== 0 || deltaZ !== 0) { + useScene.getState().updateNode(ceilingId, { + polygon: translatePolygon(originalPolygon, deltaX, deltaZ), + holes: originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)), + }) + useScene.getState().markDirty(ceilingId as AnyNodeId) + } + useLiveTransforms.getState().clear(ceilingId) + + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [ceilingId] }) + exitMoveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + clearPreview() + useViewer.getState().setSelection({ selectedIds: [ceilingId] }) + markToolCancelConsumed() + exitMoveMode() + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + if (!wasCommitted) { + clearPreview() + } else { + useLiveTransforms.getState().clear(ceilingId) + } + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + } + }, [exitMoveMode, node.id]) + + return ( + + ) +} + +/** + * Translucent fill + bright outline showing where the ceiling will land + * during the drag. Mirrors the legacy `MoveCeilingTool` overlay so the + * 3D viewer has a visible cue from above (the ceiling's child grid mesh + * is hidden by default — without this preview the only mesh that shows + * from above is the (translated) translucent ceiling itself, which is + * easy to miss). Works for both the 3D `grid:move` path (this tool + * writes `useLiveTransforms.position = [Δx, 0, Δz]` directly) and the + * 2D floor-plan move path (`slab/ceiling/floorplan-move.ts` writes the + * same value); we subscribe to that store so the preview tracks the + * current delta regardless of which mover is driving it. + */ +function CeilingMovePreview({ + ceilingId, + cursorLocalPos, + height, + originalHoles, + originalPolygon, +}: { + ceilingId: AnyNodeId + cursorLocalPos: [number, number, number] + height: number + originalHoles: Array> + originalPolygon: Array<[number, number]> +}) { + const live = useLiveTransforms((s) => s.get(ceilingId)) + const dx = live?.position[0] ?? 0 + const dz = live?.position[2] ?? 0 + + const previewPolygon = useMemo( + () => originalPolygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), + [originalPolygon, dx, dz], + ) + const previewHoles = useMemo( + () => originalHoles.map((hole) => hole.map(([x, z]) => [x + dx, z + dz] as [number, number])), + [originalHoles, dx, dz], + ) + + const previewFillGeometry = useMemo( + () => createCeilingPreviewGeometry(previewPolygon, previewHoles), + [previewPolygon, previewHoles], + ) + const previewOutlineGeometry = useMemo( + () => createCeilingOutlineGeometry(previewPolygon), + [previewPolygon], + ) + + return ( + + + + + {/* @ts-ignore - `` is a valid R3F intrinsic but conflicts with SVG line typing */} + + + + + + ) +} + +function createCeilingPreviewGeometry( + polygon: Array<[number, number]>, + holes: Array>, +): BufferGeometry { + if (polygon.length < 3) return new BufferGeometry() + + const shape = new Shape() + const first = polygon[0]! + shape.moveTo(first[0], -first[1]) + for (let i = 1; i < polygon.length; i++) { + const pt = polygon[i]! + shape.lineTo(pt[0], -pt[1]) + } + shape.closePath() + + for (const holePolygon of holes) { + if (holePolygon.length < 3) continue + const hole = new Path() + const hf = holePolygon[0]! + hole.moveTo(hf[0], -hf[1]) + for (let i = 1; i < holePolygon.length; i++) { + const pt = holePolygon[i]! + hole.lineTo(pt[0], -pt[1]) + } + 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 first = polygon[0]! + points.push(new Vector3(first[0], 0, first[1])) + geometry.setFromPoints(points) + return geometry +} + +export default MoveCeilingTool diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/nodes/src/ceiling/panel.tsx similarity index 90% rename from packages/editor/src/components/ui/panels/ceiling-panel.tsx rename to packages/nodes/src/ceiling/panel.tsx index 314f2380..67eceeff 100644 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/nodes/src/ceiling/panel.tsx @@ -1,20 +1,30 @@ 'use client' import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + PanelSection, + PanelWrapper, + SliderControl, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' -import { useCallback, useEffect } 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 { SliderControl } from '../controls/slider-control' -import { PanelWrapper } from './panel-wrapper' +import { useCallback, useEffect, useRef } from 'react' +/** + * Phase 5 Stage E — ceiling inspector (kind-owned). + * + * 1:1 port of the legacy `CeilingPanel`. Mounted via + * `parametrics.customPanel`. Same rationale as slab/panel.tsx — the + * holes list + height presets need richer field kinds before this + * panel can collapse into auto-derived groups. + */ export function CeilingPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) - const updateNode = useScene((s) => s.updateNode) const editingHole = useEditor((s) => s.editingHole) const setEditingHole = useEditor((s) => s.setEditingHole) const setMovingNode = useEditor((s) => s.setMovingNode) @@ -23,12 +33,17 @@ export function CeilingPanel() { selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined, ) + // Panel slider-drag fix recipe (plans/editor-node-registry.md): stable + // handler refs so slider drags don't trigger Maximum update depth. + const nodeRef = useRef(node) + nodeRef.current = node + const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return - updateNode(selectedId as AnyNode['id'], updates) + useScene.getState().updateNode(selectedId as AnyNode['id'], updates) }, - [selectedId, updateNode], + [selectedId], ) const handleClose = useCallback(() => { @@ -107,7 +122,7 @@ export function CeilingPanel() { const handleMove = useCallback(() => { if (!node) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) @@ -248,3 +263,5 @@ export function CeilingPanel() { ) } + +export default CeilingPanel diff --git a/packages/nodes/src/ceiling/parametrics.ts b/packages/nodes/src/ceiling/parametrics.ts new file mode 100644 index 00000000..9d63be3b --- /dev/null +++ b/packages/nodes/src/ceiling/parametrics.ts @@ -0,0 +1,19 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { CeilingNode } from './schema' + +/** + * Inspector descriptor for ceiling. + * + * Mounts the kind-owned `` via `customPanel` — same + * rationale as slab (holes list + height presets need richer field + * kinds before this can collapse into pure parametrics). + */ +export const ceilingParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [{ key: 'height', kind: 'number', unit: 'm', min: 1.5, max: 6, step: 0.05 }], + }, + ], + customPanel: () => import('./panel'), +} diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx similarity index 96% rename from packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx rename to packages/nodes/src/ceiling/renderer.tsx index 2bf712e0..339560a5 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -1,15 +1,16 @@ +'use client' + import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry, } from '@pascal-app/core' +import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import { BufferGeometry, Float32BufferAttribute } from 'three' import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' -import { useNodeEvents } from '../../../hooks/use-node-events' -import { NodeRenderer } from '../node-renderer' function createEmptyGeometry() { const geometry = new BufferGeometry() @@ -101,3 +102,5 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { ) } + +export default CeilingRenderer diff --git a/packages/nodes/src/ceiling/schema.ts b/packages/nodes/src/ceiling/schema.ts new file mode 100644 index 00000000..f562ccaa --- /dev/null +++ b/packages/nodes/src/ceiling/schema.ts @@ -0,0 +1 @@ +export { CeilingNode } from '@pascal-app/core' diff --git a/packages/nodes/src/ceiling/system.tsx b/packages/nodes/src/ceiling/system.tsx new file mode 100644 index 00000000..99f7d18f --- /dev/null +++ b/packages/nodes/src/ceiling/system.tsx @@ -0,0 +1,16 @@ +'use client' + +import { CeilingSystem } from '@pascal-app/viewer' + +/** + * Registry-driven ceiling system bundle. Wraps `CeilingSystem` so it + * mounts via `RegisteredSystems`. + * + * Future: extract polygon triangulation + hole CSG into a pure + * `buildCeilingGeometry(node)` and migrate to `def.geometry`. + */ +const CeilingSystems = () => { + return +} + +export default CeilingSystems diff --git a/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx b/packages/nodes/src/ceiling/tool.tsx similarity index 80% rename from packages/editor/src/components/tools/ceiling/ceiling-tool.tsx rename to packages/nodes/src/ceiling/tool.tsx index 59baa4c4..84226f1f 100644 --- a/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -1,72 +1,54 @@ -import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' +'use client' + +import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' +import { CursorSphere, EDITOR_LAYER, markToolCancelConsumed, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' import { mix, positionLocal } from 'three/tsl' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { CursorSphere } from '../shared/cursor-sphere' +import { CeilingNode } from './schema' + +/** + * Phase 5 Stage D — ceiling placement tool (kind-owned via `def.tool`). + * + * Multi-click polygon drawing at the ceiling height (2.52m default) + * with a vertical TSL-gradient connector + ground-shadow lines so the + * draft is visible against both the ceiling plane and the floor. + * Shift defeats the axis/45° snap during drag. + */ const CEILING_HEIGHT = 2.52 const GRID_OFFSET = 0.02 -/** - * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point - */ -const calculateSnapPoint = ( +function calculateSnapPoint( lastPoint: [number, number], currentPoint: [number, number], -): [number, number] => { +): [number, number] { const [x1, y1] = lastPoint const [x, y] = currentPoint - const dx = x - x1 const dy = y - y1 const absDx = Math.abs(dx) const absDy = Math.abs(dy) - - // Calculate distances to horizontal, vertical, and diagonal lines const horizontalDist = absDy const verticalDist = absDx const diagonalDist = Math.abs(absDx - absDy) - - // Find the minimum distance to determine which axis to snap to const minDist = Math.min(horizontalDist, verticalDist, diagonalDist) - if (minDist === diagonalDist) { - // Snap to 45° diagonal const diagonalLength = Math.min(absDx, absDy) return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength] } - if (minDist === horizontalDist) { - // Snap to horizontal - return [x, y1] - } - // Snap to vertical + if (minDist === horizontalDist) return [x, y1] return [x1, y] } -/** - * Creates a ceiling with the given polygon points and returns its ID - */ -const commitCeilingDrawing = ( - levelId: LevelNode['id'], - points: Array<[number, number]>, -): string => { +function commitCeilingDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string { const { createNode, nodes } = useScene.getState() - - // Count existing ceilings for naming const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length const name = `Ceiling ${ceilingCount + 1}` - - const ceiling = CeilingNode.parse({ - name, - polygon: points, - }) - + const ceiling = CeilingNode.parse({ name, polygon: points }) createNode(ceiling, levelId) - sfxEmitter.emit('sfx:structure-build') + triggerSFX('sfx:structure-build') return ceiling.id } @@ -78,8 +60,8 @@ export const CeilingTool: React.FC = () => { const groundMainLineRef = useRef(null!) const groundClosingLineRef = useRef(null!) const verticalLineRef = useRef(null!) - const currentLevelId = useViewer((state) => state.selection.levelId) - const setSelection = useViewer((state) => state.setSelection) + const currentLevelId = useViewer((s) => s.selection.levelId) + const setSelection = useViewer((s) => s.setSelection) const [points, setPoints] = useState>([]) const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) @@ -88,7 +70,6 @@ export const CeilingTool: React.FC = () => { const previousSnappedPointRef = useRef<[number, number] | null>(null) const shiftPressed = useRef(false) - // Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY const verticalGeo = useMemo( () => new BufferGeometry().setFromPoints([ @@ -98,51 +79,40 @@ export const CeilingTool: React.FC = () => { [], ) - // opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0 const gradientOpacityNode = useMemo( () => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()), [], ) - // Update cursor position and lines on grid move useEffect(() => { if (!currentLevelId) return const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && gridCursorRef.current)) return - const gridX = Math.round(event.localPosition[0] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2 const gridPosition: [number, number] = [gridX, gridZ] - setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) - const ceilingY = event.localPosition[1] + CEILING_HEIGHT const gridY = event.localPosition[1] + GRID_OFFSET - - // Calculate snapped display position (bypass snap when Shift is held) const lastPoint = points[points.length - 1] const displayPoint = shiftPressed.current || !lastPoint ? gridPosition : calculateSnapPoint(lastPoint, gridPosition) setSnappedCursorPosition(displayPoint) - - // Play snap sound when the snapped position actually changes (only when drawing) if ( points.length > 0 && previousSnappedPointRef.current && (displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1]) ) { - sfxEmitter.emit('sfx:grid-snap') + triggerSFX('sfx:grid-snap') } - previousSnappedPointRef.current = displayPoint cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1]) gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]) - if (verticalLineRef.current) { verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1]) } @@ -150,11 +120,7 @@ export const CeilingTool: React.FC = () => { const onGridClick = (_event: GridEvent) => { if (!currentLevelId) return - - // Use the last displayed snapped position (respects Shift state from onGridMove) const clickPoint = previousSnappedPointRef.current ?? cursorPosition - - // Check if clicking on the first point to close the shape const firstPoint = points[0] if ( points.length >= 3 && @@ -162,20 +128,16 @@ export const CeilingTool: React.FC = () => { Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 ) { - // Create the ceiling and select it const ceilingId = commitCeilingDrawing(currentLevelId, points) setSelection({ selectedIds: [ceilingId] }) setPoints([]) } else { - // Add point to polygon setPoints([...points, clickPoint]) } } const onGridDoubleClick = (_event: GridEvent) => { if (!currentLevelId) return - - // Need at least 3 points to form a polygon if (points.length >= 3) { const ceilingId = commitCeilingDrawing(currentLevelId, points) setSelection({ selectedIds: [ceilingId] }) @@ -212,33 +174,26 @@ export const CeilingTool: React.FC = () => { } }, [currentLevelId, points, cursorPosition, setSelection]) - // Update line geometries when points change useEffect(() => { if (!(mainLineRef.current && closingLineRef.current)) return - if (points.length === 0) { mainLineRef.current.visible = false closingLineRef.current.visible = false + groundMainLineRef.current && (groundMainLineRef.current.visible = false) + groundClosingLineRef.current && (groundClosingLineRef.current.visible = false) return } - const ceilingY = levelY + CEILING_HEIGHT const snappedCursor = snappedCursorPosition - - // Build main line points const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z)) linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1])) - const gridY = levelY + GRID_OFFSET const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z)) groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1])) - - // Update main line if (linePoints.length >= 2) { mainLineRef.current.geometry.dispose() mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints) mainLineRef.current.visible = true - groundMainLineRef.current.geometry.dispose() groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints) groundMainLineRef.current.visible = true @@ -246,8 +201,6 @@ export const CeilingTool: React.FC = () => { mainLineRef.current.visible = false groundMainLineRef.current.visible = false } - - // Update closing line (from cursor back to first point) const firstPoint = points[0] if (points.length >= 2 && firstPoint) { const closingPoints = [ @@ -257,7 +210,6 @@ export const CeilingTool: React.FC = () => { closingLineRef.current.geometry.dispose() closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints) closingLineRef.current.visible = true - const groundClosingPoints = [ new Vector3(snappedCursor[0], gridY, snappedCursor[1]), new Vector3(firstPoint[0], gridY, firstPoint[1]), @@ -273,40 +225,25 @@ export const CeilingTool: React.FC = () => { } }, [points, snappedCursorPosition, levelY]) - // Create preview shape when we have 3+ points const previewShape = useMemo(() => { if (points.length < 3) return null - const snappedCursor = snappedCursorPosition - const allPoints = [...points, snappedCursor] - - // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X: - // - Shape X -> World X - // - Shape Y -> World -Z (so we negate Z to get correct orientation) const firstPt = allPoints[0] if (!firstPt) return null - const shape = new Shape() shape.moveTo(firstPt[0], -firstPt[1]) - for (let i = 1; i < allPoints.length; i++) { const pt = allPoints[i] - if (pt) { - shape.lineTo(pt[0], -pt[1]) - } + if (pt) shape.lineTo(pt[0], -pt[1]) } shape.closePath() - return shape }, [points, snappedCursorPosition]) return ( - {/* Cursor at ceiling height */} - - {/* Grid-level cursor indicator */} { transparent /> - - {/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */} {/* @ts-ignore */} { transparent /> - - {/* Preview fill (Top) */} {previewShape && ( { /> )} - - {/* Preview fill (Ground) */} {previewShape && ( { /> )} - - {/* Main line */} {/* @ts-ignore */} { - - {/* Closing line */} {/* @ts-ignore */} { transparent /> - - {/* Ground main line */} {/* @ts-ignore */} { transparent /> - - {/* Ground closing line */} {/* @ts-ignore */} { transparent /> - - {/* Point markers */} {points.map(([x, z], index) => ( { ) } + +export default CeilingTool diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts new file mode 100644 index 00000000..080d2869 --- /dev/null +++ b/packages/nodes/src/column/definition.ts @@ -0,0 +1,80 @@ +import { + ColumnNode as ColumnNodeSchema, + type ColumnNode as ColumnNodeType, + type NodeDefinition, +} from '@pascal-app/core' +import { buildColumnFloorplan } from './floorplan' +import { columnParametrics } from './parametrics' +import { ColumnNode } from './schema' + +/** + * Column — Stage A registration. Wrap-export of the legacy + * `ColumnRenderer` (no system — column geometry is computed inline in + * the renderer). Inspector / move / floorplan still go through legacy + * paths via panel-manager.tsx / item-move-tool.tsx / floorplan-panel.tsx + * (their hardcoded `case 'column':` entries fire before the registry + * fallback). + * + * Capabilities: column doesn't declare `movable` because its move is + * bespoke (legacy MoveColumnTool snaps to slab + free placement on + * the X/Z plane with rotation). + * + * Defaults computed via stub-parse so we leverage every zod + * `.default()` annotation on the schema (~60 fields). + */ +export const columnDefinition: NodeDefinition = { + kind: 'column', + schemaVersion: 1, + schema: ColumnNode, + category: 'structure', + + defaults: () => { + const stub = ColumnNodeSchema.parse({ id: 'column_default' as never, type: 'column' }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + // Slab elevation lift via the generic ``. + floorPlaced: { + footprint: (node) => { + const column = node as ColumnNodeType + return { + dimensions: [column.width, column.height, column.depth] as [number, number, number], + // Column stores Y rotation as a scalar; the slab-overlap query + // expects the full Euler tuple. + rotation: [0, column.rotation, 0] as [number, number, number], + } + }, + }, + }, + + parametrics: columnParametrics, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + // Stage D — 3D move-tool (registry-driven). Replaces the legacy + // `MoveColumnTool` in editor's dispatcher. Same 0.5m grid snap + + // live-transform preview the legacy used. + affordanceTools: { + move: () => import('./move-tool'), + }, + floorplan: buildColumnFloorplan, + + presentation: { + label: 'Column', + description: 'A parametric column with configurable cross-section, base, and capital.', + icon: { kind: 'url', src: '/icons/column.png' }, + paletteSection: 'structure', + paletteOrder: 70, + }, + + mcp: { + description: 'A parametric column placed on a slab or level.', + }, +} diff --git a/packages/nodes/src/column/floorplan.ts b/packages/nodes/src/column/floorplan.ts new file mode 100644 index 00000000..7b21bc61 --- /dev/null +++ b/packages/nodes/src/column/floorplan.ts @@ -0,0 +1,185 @@ +import type { + ColumnNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, +} from '@pascal-app/core' + +/** + * Stage C floor-plan builder for column. Inlined from the legacy + * `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The + * footprint shape depends on `crossSection` (square / rectangular / + * round / octagonal / sixteen-sided) and `supportStyle` (vertical / + * a-frame / x-brace / etc.) — brace supports use a rotated rectangle + * spanning the base spread; standalone columns use the shaft profile. + * + * When selected, switches to a themed accent stroke and emits a move + * handle at the column center. No dimension overlay (columns don't + * have a natural "length" axis like a wall). + */ +export function buildColumnFloorplan( + node: ColumnNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const polygon = getColumnPlanFootprint(node) + if (polygon.length < 3) return null + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const showSelectedChrome = isSelected || isHighlighted + + const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151' + const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af' + + const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint) + + const children: FloorplanGeometry[] = [ + { + kind: 'polygon', + points, + fill, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.02, + opacity: 0.92, + }, + ] + + // Hatch overlay on selected — same `` pattern as the wall. + if (isSelected && palette) { + children.push({ + kind: 'hatch', + points, + color: palette.selectedHatch, + opacity: 0.7, + }) + } + + // Move handle at the column center when selected. + if (isSelected) { + children.push({ + kind: 'move-handle', + point: [node.position[0], node.position[2]], + }) + } + + return { kind: 'group', children } +} + +// ── Inlined helpers from legacy floorplan-panel.tsx ─────────────────── + +type PlanPoint = { x: number; y: number } + +function rotatePlanVector(x: number, y: number, rotation: number): [number, number] { + const c = Math.cos(rotation) + const s = Math.sin(rotation) + return [x * c - y * s, x * s + y * c] +} + +function getRotatedRectanglePolygon( + center: PlanPoint, + width: number, + depth: number, + rotation: number, +): PlanPoint[] { + const halfW = width / 2 + const halfD = depth / 2 + const corners: Array<[number, number]> = [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ] + return corners.map(([x, y]) => { + const [rx, ry] = rotatePlanVector(x, y, rotation) + return { x: center.x + rx, y: center.y + ry } + }) +} + +function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] { + const center: PlanPoint = { x: column.position[0], y: column.position[2] } + + // Brace-support columns: rotated rectangle spanning the base spread. + if ( + column.supportStyle === 'a-frame' || + column.supportStyle === 'y-frame' || + column.supportStyle === 'v-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ) { + const width = Math.max( + column.supportStyle === 'a-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ? (column.braceBottomSpread ?? 1.2) + : 0, + column.braceTopSpread ?? + (column.supportStyle === 'y-frame' || + column.supportStyle === 'v-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ? 1 + : 0), + (column.braceWidth ?? column.width) * 2, + ) + const depth = Math.max( + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'box-frame' + ? (column.braceTopSpread ?? 1) + : 0, + column.braceDepth ?? column.depth, + 0.08, + ) + return getRotatedRectanglePolygon(center, width, depth, column.rotation) + } + + // Standalone column: shaft profile expanded for base + capital. + const isRound = + column.crossSection === 'round' || + column.crossSection === 'octagonal' || + column.crossSection === 'sixteen-sided' + const shaftWidth = isRound ? column.radius * 2 : column.width + const shaftDepth = isRound ? column.radius * 2 : column.depth + const width = Math.max( + shaftWidth, + column.width * column.baseWidthScale, + column.width * column.capitalWidthScale, + ) + const depth = Math.max( + shaftDepth, + column.depth * column.baseDepthScale, + column.depth * column.capitalDepthScale, + ) + + if (column.crossSection === 'square' || column.crossSection === 'rectangular') { + return getRotatedRectanglePolygon(center, width, depth, column.rotation) + } + + const segmentCount = + column.crossSection === 'octagonal' ? 8 : column.crossSection === 'sixteen-sided' ? 16 : 32 + + return Array.from({ length: segmentCount }, (_, index) => { + const angle = (index / segmentCount) * Math.PI * 2 + const localX = Math.cos(angle) * (width / 2) + const localY = Math.sin(angle) * (depth / 2) + const [offsetX, offsetY] = rotatePlanVector(localX, localY, column.rotation) + return { x: center.x + offsetX, y: center.y + offsetY } + }) +} diff --git a/packages/nodes/src/column/index.ts b/packages/nodes/src/column/index.ts new file mode 100644 index 00000000..e3839c25 --- /dev/null +++ b/packages/nodes/src/column/index.ts @@ -0,0 +1 @@ +export { columnDefinition } from './definition' diff --git a/packages/editor/src/components/tools/column/move-column-tool.tsx b/packages/nodes/src/column/move-tool.tsx similarity index 71% rename from packages/editor/src/components/tools/column/move-column-tool.tsx rename to packages/nodes/src/column/move-tool.tsx index ae02e102..cedd62a2 100644 --- a/packages/editor/src/components/tools/column/move-column-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -1,24 +1,36 @@ -import '../../../three-types' +'use client' import { type AnyNodeId, - ColumnNode, - type ColumnNode as ColumnNodeType, + type ColumnNode, + ColumnNode as ColumnNodeSchema, emitter, type GridEvent, sceneRegistry, useLiveTransforms, useScene, } from '@pascal-app/core' +import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { useCallback, useEffect, 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' +/** + * Phase 5 Stage D — column's registry-driven 3D move affordance. + * + * Replaces the legacy `MoveColumnTool` in `editor/src/components/tools/ + * column/move-column-tool.tsx`. Behaviour is identical: grid:move + * snaps the cursor to a 0.5m grid and previews the column at that + * position via `useLiveTransforms` + a direct `sceneRegistry.nodes.get + * (id).position.set(...)` (the live-drag exception documented in + * `wiki/architecture/tools.md`); grid:click commits via `useScene. + * updateNode`. Cancel restores the pre-drag position. + * + * Wired via `def.affordanceTools.move`. The editor's `MoveTool` + * dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup + * picks this up before its legacy chain reaches ``. + */ const roundToHalf = (value: number) => Math.round(value * 2) / 2 -export function MoveColumnTool({ node }: { node: ColumnNodeType }) { +function MoveColumnTool({ node }: { node: ColumnNode }) { const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) const exitMoveMode = useCallback(() => { @@ -48,7 +60,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) { 0, roundToHalf(event.localPosition[2]), ] - const nodeId = (node as { id?: ColumnNodeType['id'] }).id + const nodeId = (node as { id?: ColumnNode['id'] }).id if (nodeId && useScene.getState().nodes[nodeId]) { committed = true @@ -56,7 +68,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) { useScene.temporal.getState().resume() useScene.getState().updateNode(nodeId, { position }) } else if (node.parentId) { - const column = ColumnNode.parse({ + const column = ColumnNodeSchema.parse({ ...node, id: undefined, metadata: {}, @@ -68,7 +80,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) { } useLiveTransforms.getState().clear(node.id) - sfxEmitter.emit('sfx:item-place') + triggerSFX('sfx:item-place') exitMoveMode() event.nativeEvent?.stopPropagation?.() } @@ -103,3 +115,5 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) { return } + +export default MoveColumnTool diff --git a/packages/editor/src/components/ui/panels/column-panel.tsx b/packages/nodes/src/column/panel.tsx similarity index 68% rename from packages/editor/src/components/ui/panels/column-panel.tsx rename to packages/nodes/src/column/panel.tsx index 5398953e..e5a19f02 100644 --- a/packages/editor/src/components/ui/panels/column-panel.tsx +++ b/packages/nodes/src/column/panel.tsx @@ -7,17 +7,20 @@ import { type ColumnPresetId, useScene, } from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + cn, + PanelSection, + PanelWrapper, + SliderControl, + ToggleControl, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { cn } from '../../../lib/utils' -import useEditor from '../../../store/use-editor' -import { ActionButton, ActionGroup } from '../controls/action-button' -import { PanelSection } from '../controls/panel-section' -import { SliderControl } from '../controls/slider-control' -import { ToggleControl } from '../controls/toggle-control' -import { PanelWrapper } from './panel-wrapper' const SELECT_CLASS = 'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border' @@ -178,7 +181,7 @@ function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial< } } -export function ColumnPanel() { +export default function ColumnPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) @@ -204,14 +207,14 @@ export function ColumnPanel() { const handleDelete = useCallback(() => { if (!selectedId) return - sfxEmitter.emit('sfx:structure-delete') + triggerSFX('sfx:structure-delete') deleteNode(selectedId as AnyNode['id']) setSelection({ selectedIds: [] }) }, [deleteNode, selectedId, setSelection]) const handleMove = useCallback(() => { if (!node) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) @@ -488,9 +491,7 @@ export function ColumnPanel() { { - const capitalStyle = event.target.value as ColumnNode['capitalStyle'] - handleUpdate({ - capitalStyle, - ...(capitalStyle === 'none' - ? {} - : { - capitalHeight: Math.max(node.capitalHeight, 0.12), - capitalTierCount: - capitalStyle === 'stepped' - ? Math.max(node.capitalTierCount ?? 3, 3) - : node.capitalTierCount, - capitalWidthScale: Math.max( - node.capitalWidthScale ?? 1.3, - capitalStyle === 'stepped' ? 1.42 : 1.28, - ), - capitalDepthScale: Math.max( - node.capitalDepthScale ?? 1.3, - capitalStyle === 'stepped' ? 1.42 : 1.28, - ), - capitalStepSpread: - capitalStyle === 'stepped' - ? Math.max(node.capitalStepSpread ?? 0.34, 0.34) - : node.capitalStepSpread, - }), - }) - }} - value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')} - > - - - - - - {node.capitalStyle !== 'none' && ( - handleUpdate({ capitalHeight: value })} - precision={2} - step={0.02} - unit="m" - value={node.capitalHeight} - /> - )} - {node.capitalStyle !== 'none' && ( - + { - const baseStyle = event.target.value as ColumnNode['baseStyle'] - handleUpdate({ - baseStyle, - ...(baseStyle === 'none' - ? {} - : { - baseHeight: Math.max(node.baseHeight, 0.12), - baseTierCount: - baseStyle === 'stepped-square' - ? Math.max(node.baseTierCount ?? 3, 3) - : node.baseTierCount, - baseWidthScale: Math.max( - node.baseWidthScale ?? 1.24, - baseStyle === 'stepped-square' ? 1.42 : 1.24, - ), - baseDepthScale: Math.max( - node.baseDepthScale ?? 1.24, - baseStyle === 'stepped-square' ? 1.42 : 1.24, - ), - baseStepSpread: - baseStyle === 'stepped-square' - ? Math.max(node.baseStepSpread ?? 0.34, 0.34) - : node.baseStepSpread, - basePlinthHeightRatio: - baseStyle === 'round-rings' - ? (node.basePlinthHeightRatio ?? 0.44) - : node.basePlinthHeightRatio, - baseRoundBandScale: - baseStyle === 'round-rings' - ? (node.baseRoundBandScale ?? 0.92) - : node.baseRoundBandScale, - baseNeckScale: - baseStyle === 'round-rings' - ? (node.baseNeckScale ?? 0.72) - : node.baseNeckScale, - }), - }) - }} - value={node.baseStyle ?? 'square-plinth'} - > - - - - - - - {node.baseStyle !== 'none' && ( - handleUpdate({ baseHeight: value })} - precision={2} - step={0.02} - unit="m" - value={node.baseHeight} - /> - )} - {node.baseStyle !== 'none' && ( - + }} + value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')} + > + + + + + + {node.capitalStyle !== 'none' && ( + handleUpdate({ capitalHeight: value })} + precision={2} + step={0.02} + unit="m" + value={node.capitalHeight} + /> + )} + {node.capitalStyle !== 'none' && ( + + handleUpdate({ + capitalWidthScale: value, + ...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }), + }) + } + precision={2} + step={0.02} + value={node.capitalWidthScale ?? 1.28} + /> + )} + {node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && ( + handleUpdate({ capitalDepthScale: value })} + precision={2} + step={0.02} + value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28} + /> + )} + {node.capitalStyle === 'stepped' && ( + handleUpdate({ capitalTierCount: Math.round(value) })} + precision={0} + step={1} + value={node.capitalTierCount ?? 3} + /> + )} + {node.capitalStyle === 'stepped' && ( + handleUpdate({ capitalStepSpread: value })} + precision={2} + step={0.01} + value={node.capitalStepSpread ?? 0.34} + /> + )} + + {node.baseStyle !== 'none' && ( + handleUpdate({ baseHeight: value })} + precision={2} + step={0.02} + unit="m" + value={node.baseHeight} + /> + )} + {node.baseStyle !== 'none' && ( + + handleUpdate({ + baseWidthScale: value, + ...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }), + }) + } + precision={2} + step={0.02} + value={node.baseWidthScale ?? 1.24} + /> + )} + {node.baseStyle !== 'none' && node.crossSection === 'rectangular' && ( + handleUpdate({ baseDepthScale: value })} + precision={2} + step={0.02} + value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ basePlinthHeightRatio: value })} + precision={2} + step={0.01} + value={node.basePlinthHeightRatio ?? 0.44} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ baseRoundBandScale: value })} + precision={2} + step={0.01} + value={node.baseRoundBandScale ?? 0.92} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ baseNeckScale: value })} + precision={2} + step={0.01} + value={node.baseNeckScale ?? 0.72} + /> + )} + {node.baseStyle === 'stepped-square' && ( + handleUpdate({ baseTierCount: Math.round(value) })} + precision={0} + step={1} + value={node.baseTierCount ?? 3} + /> + )} + {node.baseStyle === 'stepped-square' && ( + handleUpdate({ baseStepSpread: value })} + precision={2} + step={0.01} + value={node.baseStepSpread ?? 0.34} + /> + )} )} diff --git a/packages/nodes/src/column/parametrics.ts b/packages/nodes/src/column/parametrics.ts new file mode 100644 index 00000000..30ac0aaa --- /dev/null +++ b/packages/nodes/src/column/parametrics.ts @@ -0,0 +1,24 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { ColumnNode } from './schema' + +/** + * Stage A inspector — minimal. Column has 60+ schema fields (cross- + * section, shaft profile, capital style, base style, carvings, ring + * placement, etc.); the legacy `` renders these via + * panel-manager's hardcoded switch. The descriptor below registers + * the kind as "has parametric data" without trying to express the + * full legacy panel — Stage E will replace it via `customPanel`. + */ +export const columnParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'height', kind: 'number', unit: 'm', min: 0.5, max: 6, step: 0.05 }, + { key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 }, + ], + }, + ], + customPanel: () => import('./panel'), +} diff --git a/packages/viewer/src/components/renderers/column/column-renderer.tsx b/packages/nodes/src/column/renderer.tsx similarity index 99% rename from packages/viewer/src/components/renderers/column/column-renderer.tsx rename to packages/nodes/src/column/renderer.tsx index e90e02d1..8f1704f0 100644 --- a/packages/viewer/src/components/renderers/column/column-renderer.tsx +++ b/packages/nodes/src/column/renderer.tsx @@ -1,14 +1,18 @@ +'use client' + import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' -import { createContext, useContext, useMemo, useRef } from 'react' -import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three' -import { useNodeEvents } from '../../../hooks/use-node-events' -import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' import { + baseMaterial, createColumnBoxGeometry, createColumnCylinderGeometry, createColumnSphereGeometry, createColumnTorusGeometry, -} from '../../../systems/column/column-geometry' + createMaterial, + createMaterialFromPresetRef, + useNodeEvents, +} from '@pascal-app/viewer' +import { createContext, useContext, useMemo, useRef } from 'react' +import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three' const ColumnMaterialContext = createContext(baseMaterial as Material) const ColumnEdgeSoftnessContext = createContext(0.025) @@ -2165,3 +2169,5 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => { ) } + +export default ColumnRenderer diff --git a/packages/nodes/src/column/schema.ts b/packages/nodes/src/column/schema.ts new file mode 100644 index 00000000..6bd6e6a5 --- /dev/null +++ b/packages/nodes/src/column/schema.ts @@ -0,0 +1 @@ +export { ColumnNode } from '@pascal-app/core' diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts new file mode 100644 index 00000000..49595e52 --- /dev/null +++ b/packages/nodes/src/door/definition.ts @@ -0,0 +1,96 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildDoorFloorplan } from './floorplan' +import { doorFloorplanMoveTarget } from './floorplan-move' +import { doorParametrics } from './parametrics' +import { DoorNode } from './schema' + +/** + * Door — Phase 5 batch kind. Hosted on walls, cuts holes in them, + * animated open/close state. + * + * Capabilities: + * - **No `movable`**: door's move is bespoke wall-bound drag (slide + * along the wall, snap to wall start/end). Capability-driven dispatch + * keeps legacy `MoveDoorTool`. + * - `selectable`, `duplicable`, `deletable` standard. + * + * Stages: + * - A: registered. + * - B: deferred — door geometry (frame / leaf / glass / hardware / + * segments) is ~800 lines in DoorSystem; extraction is a focused + * session. `def.renderer` (wrap-export of legacy DoorRenderer) + + * `def.system` (DoorSystem + DoorAnimationSystem bundle) hold parity. + * - C: `def.floorplan` polygon sits in parent wall's cutout. Legacy + * `openingPolygons` short-circuits door entries when registered. + */ +export const doorDefinition: NodeDefinition = { + kind: 'door', + schemaVersion: 1, + schema: DoorNode, + category: 'structure', + + // Leverage the schema's zod `.default()` annotations to compute the + // full default shape — door has 40+ fields, listing them inline would + // duplicate the schema. Parse a minimal stub, drop id/type, return rest. + defaults: () => { + const stub = DoorNode.parse({ id: 'door_default' as never, type: 'door' }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: doorParametrics, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + system: { + module: () => import('./system'), + // Priority 3 mirrors the legacy DoorSystem (after animation at 2, + // before wall mitering at 4). + priority: 3, + }, + // Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute + // direction + perpendicular for the cutout footprint. + floorplan: buildDoorFloorplan, + // Stage D — placement (`def.tool`) + move-on-wall (`def. + // affordanceTools.move`). Both ports of the legacy tools at + // `editor/components/tools/door/`, relocated into the kind folder and + // wired through ToolManager's registry-first dispatch (`def.tool` for + // build-mode placement, `getRegistryAffordanceTool` for the move-on- + // pick flow). Same legacy semantics: wall-event-driven snap, clamped + // wall-local coords, hasWallChildOverlap guard, live mesh updates. + tool: () => import('./tool'), + affordanceTools: { + move: () => import('./move-tool'), + }, + // 2D move-on-floorplan handler. When `useEditor.movingNode` is a + // door and the floor plan is active, `FloorplanRegistryMoveOverlay` + // dispatches to this instead of the generic translate path — pointer + // snaps to the nearest wall, projects onto the wall axis, snaps + // local-X to 0.5m, clamps inside wall bounds. + floorplanMoveTarget: doorFloorplanMoveTarget, + + toolHints: [ + { key: 'Left click', label: 'Place door on wall' }, + { key: 'Esc', label: 'Cancel' }, + ], + + presentation: { + label: 'Door', + description: 'A door cut into a wall. Animated open/close state.', + icon: { kind: 'url', src: '/icons/door.png' }, + paletteSection: 'structure', + paletteOrder: 50, + }, + + mcp: { + description: 'A door mounted on a wall, with type / dimensions / hardware options.', + }, +} diff --git a/packages/editor/src/components/tools/door/door-math.ts b/packages/nodes/src/door/door-math.ts similarity index 100% rename from packages/editor/src/components/tools/door/door-math.ts rename to packages/nodes/src/door/door-math.ts diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts new file mode 100644 index 00000000..d95ab68b --- /dev/null +++ b/packages/nodes/src/door/floorplan-move.ts @@ -0,0 +1,87 @@ +import { + type AnyNodeId, + type DoorNode, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + useScene, +} from '@pascal-app/core' +import { snapToHalf } from '@pascal-app/editor' +import { findClosestWallInPlan } from '../shared/wall-attach-target' +import { clampToWall, hasWallChildOverlap } from './door-math' + +/** + * 2D floor-plan move handler for door — kicks in when the user clicks + * "Move" on the door inspector (or action menu) and the floor-plan + * view is active. Pointer in plan space → snap to nearest wall → + * project onto wall axis → snap local-X to 0.5m grid → clamp inside + * wall bounds → commit via `useScene.updateNodes`. + * + * Mirrors the 3D `move-tool.tsx` behaviour minus the R3F event plumbing: + * - Re-parents on transition between walls (parentId + wallId). + * - Adapts `side` + `rotation` from the wall normal under the pointer. + * - hasWallChildOverlap blocks committing overlapping placements. + * + * Curved walls are skipped by `findClosestWallInPlan` — same guardrail + * as the 3D port and the legacy `DoorTool` / `MoveDoorTool`. + */ + +export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + // Snapshot of the door's "valid" state at move-start — used by + // canCommit to decide whether the current snapped position is OK. + const startLevelId = (() => { + // Walk up via parentId until we hit a node whose type isn't 'wall' + // — that's the level (or null). The door is wall-hosted, so the + // wall's parent is the level. Cached at start because the parent + // chain doesn't change during a move. + const wall = useScene.getState().nodes[node.parentId as AnyNodeId] + return wall ? (wall.parentId as AnyNodeId | null) : null + })() + + const session: FloorplanMoveTargetSession = { + affectedIds: [node.id as AnyNodeId], + apply({ planPoint, modifiers }) { + const nodes = useScene.getState().nodes + const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) + if (!hit) return // pointer off any wall — keep door at last valid position + + // Snap the wall-local X to 0.5m grid (Shift bypasses). + const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX) + const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) + + // Build the updates atomically — position + rotation + side + + // parentId + wallId in a single scene write. The current door's + // parent might be a different wall; re-anchoring requires moving + // the node in the parent's children list (the registry's + // updateNode does this when parentId changes). + useScene.getState().updateNodes([ + { + id: node.id as AnyNodeId, + data: { + position: [clampedX, clampedY, 0], + rotation: [0, hit.itemRotation, 0], + side: hit.side, + parentId: hit.wall.id, + wallId: hit.wall.id, + }, + }, + ]) + }, + canCommit() { + const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined + if (!live || live.type !== 'door') return false + // Block commit if the door overlaps any other wall child at its + // current position. The 3D port has the same guard. + const overlapping = hasWallChildOverlap( + live.parentId as string, + live.position[0], + live.position[1], + live.width, + live.height, + live.id, + ) + return !overlapping + }, + } + + return session +} diff --git a/packages/nodes/src/door/floorplan.ts b/packages/nodes/src/door/floorplan.ts new file mode 100644 index 00000000..94fa2131 --- /dev/null +++ b/packages/nodes/src/door/floorplan.ts @@ -0,0 +1,212 @@ +import type { + DoorNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + WallNode, +} from '@pascal-app/core' +import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions' + +/** + * Stage C floor-plan builder for door. 1:1 visual port of the legacy + * floorplan-panel door rendering: + * + * 1. The door footprint rectangle in the wall cutout (themed + * accent stroke when selected). + * 2. The door swing arc — a quarter-circle from the hinge to the + * door's open position, modulated by `swingAngle`, `hingesSide`, + * and `swingDirection`. Renders as a wedge of low-opacity fill so + * the swept area reads at a glance. + * 3. The door leaf — a thick line from the hinge to the open + * position, terminating at the arc end. + * 4. Center line through the cutout (matches the legacy's + * `getOpeningCenterLine` segment for visual continuity). + * + * Requires `ctx.parent` to be a wall (door.parentId is the wall it's + * mounted on). Returns null when the parent isn't a wall (orphaned + * doors during placement etc.). + * + * Skipped vs the full legacy for now: hinge / strike cubes (small + * indicator squares at the rotation pivots), rounded-opening shape + * variants, panic bar markers. Those are rare visual variations the + * follow-up port can revisit. + */ +export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null { + const wall = ctx.parent as WallNode | null + if (!wall || wall.type !== 'wall') return null + + const [x1, z1] = wall.start + const [x2, z2] = wall.end + const dx = x2 - x1 + const dz = z2 - z1 + const length = Math.sqrt(dx * dx + dz * dz) + if (length < 1e-9) return null + + const dirX = dx / length + const dirZ = dz / length + // Perpendicular unit normal (rotate 90° CCW). + const perpX = -dirZ + const perpZ = dirX + + const distance = node.position[0] + const width = node.width + const depth = wall.thickness ?? 0.1 + const cx = x1 + dirX * distance + const cz = z1 + dirZ * distance + const halfWidth = width / 2 + const halfDepth = depth / 2 + + const isPlanFlipped = isOpeningPlanFlipped(node.rotation) + const baseHingesSide = node.hingesSide ?? 'left' + const baseSwingDirection = node.swingDirection ?? 'inward' + const hingesSide = isPlanFlipped ? (baseHingesSide === 'left' ? 'right' : 'left') : baseHingesSide + const swingDirection = isPlanFlipped + ? baseSwingDirection === 'inward' + ? 'outward' + : 'inward' + : baseSwingDirection + const swingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0)) + + // Footprint rectangle in the cutout. + const points: readonly FloorplanPoint[] = [ + [cx - dirX * halfWidth + perpX * halfDepth, cz - dirZ * halfWidth + perpZ * halfDepth], + [cx + dirX * halfWidth + perpX * halfDepth, cz + dirZ * halfWidth + perpZ * halfDepth], + [cx + dirX * halfWidth - perpX * halfDepth, cz + dirZ * halfWidth - perpZ * halfDepth], + [cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth], + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Match the legacy floor-plan door render: unselected is a quiet + // grey accent so the door reads as a hole in the wall, selected is + // a full orange treatment (body + outline) so the user can see at + // a glance which door is targeted by the inspector / move handle. + const accentColor = showSelectedChrome ? '#f97316' : 'rgba(100, 116, 139, 0.82)' + const accentMuted = accentColor + const fillColor = showSelectedChrome ? '#fed7aa' : '#ffffff' + + const children: FloorplanGeometry[] = [ + // Background — the cutout is filled white so the swing arc sits on + // a clean canvas (the wall hatch shows through otherwise). + { + kind: 'polygon', + points, + fill: fillColor, + stroke: accentMuted, + strokeWidth: showSelectedChrome ? 2 : 1.25, + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }, + ] + + // Swing geometry. The hinge sits at one end of the door along the + // wall direction; the strike sits at the opposite end. The leaf + // rotates around the hinge by `swingAngle` toward the inward / + // outward side of the wall. + const hingeTangentSign = hingesSide === 'left' ? 1 : -1 + const swingSign = swingDirection === 'inward' ? 1 : -1 + const hingeX = cx - dirX * halfWidth * hingeTangentSign + const hingeZ = cz - dirZ * halfWidth * hingeTangentSign + // Closed leaf vector points from hinge to strike (along the wall). + const closedLeafX = dirX * width * hingeTangentSign + const closedLeafZ = dirZ * width * hingeTangentSign + + if (swingAngle > 1e-3 && width > 1e-3) { + // Rotate the closed leaf vector by `swingAngle * swingSign * + // hingeTangentSign` around the hinge to get the open leaf tip. + const angle = swingAngle * swingSign * hingeTangentSign + const cos = Math.cos(angle) + const sin = Math.sin(angle) + const openLeafX = closedLeafX * cos - closedLeafZ * sin + const openLeafZ = closedLeafX * sin + closedLeafZ * cos + const tipX = hingeX + openLeafX + const tipZ = hingeZ + openLeafZ + + // Closed leaf tip — where the leaf would land if fully closed. + const closedTipX = hingeX + closedLeafX + const closedTipZ = hingeZ + closedLeafZ + + // Swing arc — a path from closed tip to open tip via an arc + // centered at the hinge. SVG's A command takes rx ry rotation + // large-arc-flag sweep-flag x y. Sweep flag flips based on the + // signed angle direction. + const sweepFlag = angle >= 0 ? 1 : 0 + const arcPath = `M ${closedTipX} ${closedTipZ} A ${width} ${width} 0 0 ${sweepFlag} ${tipX} ${tipZ}` + + // Swept wedge fill (light, low opacity) — gives the door a + // visible "this is the open zone" treatment. + children.push({ + kind: 'path', + d: `M ${hingeX} ${hingeZ} L ${closedTipX} ${closedTipZ} ${arcPath + .replace(/^M [^A]+/, '') + .trim()} Z`, + fill: accentColor, + fillOpacity: showSelectedChrome ? 0.08 : 0.05, + stroke: 'none', + }) + + // The arc itself, stroked. + children.push({ + kind: 'path', + d: arcPath, + fill: 'none', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.6 : 1.1, + strokeOpacity: 0.85, + vectorEffect: 'non-scaling-stroke', + strokeLinecap: 'round', + }) + + // The door leaf — line from hinge to the open tip. + children.push({ + kind: 'line', + x1: hingeX, + y1: hingeZ, + x2: tipX, + y2: tipZ, + stroke: accentColor, + strokeWidth: showSelectedChrome ? 2.4 : 1.7, + strokeLinecap: 'round', + vectorEffect: 'non-scaling-stroke', + }) + } + + // Move handle — orange dot at the door center. Only visible when + // selected. Pointer-down on this triggers `setMovingNode(door)` + // → `FloorplanRegistryMoveOverlay` → `def.floorplanMoveTarget`. + if (isSelected) { + children.push({ + kind: 'move-handle', + point: [cx, cz], + }) + } + + // Placement-measurement dimensions — distances to adjacent openings + // (or wall ends) on each side. Only visible while actively moving + // (the user clicked Move or grabbed the orange dot). + if (view?.moving) { + for (const dim of buildOpeningPlacementDimensions(node, ctx)) { + children.push(dim) + } + } + + return { kind: 'group', children } +} + +/** + * The opening's wall-normal orientation is encoded in the door's Y + * rotation. When the door faces "inward" along an angle in [π/2, 3π/2], + * the rendering needs the hinge side + swing direction flipped to + * keep the visual swing on the correct side of the wall. + * + * Mirrors `isOpeningPlanFlipped` in `floorplan-panel.tsx`. + */ +function isOpeningPlanFlipped(rotation: readonly [number, number, number]): boolean { + const normalized = + ((((rotation[1] % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) + 1e-6) % (Math.PI * 2) + return normalized > Math.PI / 2 && normalized < (Math.PI * 3) / 2 +} diff --git a/packages/nodes/src/door/index.ts b/packages/nodes/src/door/index.ts new file mode 100644 index 00000000..364ea491 --- /dev/null +++ b/packages/nodes/src/door/index.ts @@ -0,0 +1,2 @@ +export { doorDefinition } from './definition' +export { DoorNode } from './schema' diff --git a/packages/editor/src/components/tools/door/move-door-tool.tsx b/packages/nodes/src/door/move-tool.tsx similarity index 97% rename from packages/editor/src/components/tools/door/move-door-tool.tsx rename to packages/nodes/src/door/move-tool.tsx index ee1a80c3..feb38d2b 100644 --- a/packages/editor/src/components/tools/door/move-door-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -9,20 +9,20 @@ import { useScene, type WallEvent, } from '@pascal-app/core' +import { + calculateCursorRotation, + calculateItemRotation, + EDITOR_LAYER, + getSideFromNormal, + isValidWallSideFace, + snapToHalf, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { - calculateCursorRotation, - calculateItemRotation, - getSideFromNormal, - isValidWallSideFace, - snapToHalf, -} from '../item/placement-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' const edgeMaterial = new LineBasicNodeMaterial({ @@ -32,7 +32,7 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) -export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { +const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const cursorGroupRef = useRef(null!) const exitMoveMode = useCallback(() => { @@ -310,7 +310,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod useLiveTransforms.getState().clear(movingDoorNode.id) useScene.temporal.getState().pause() - sfxEmitter.emit('sfx:item-place') + triggerSFX('sfx:item-place') hideCursor() useViewer.getState().setSelection({ selectedIds: [placedId] }) exitMoveMode() @@ -410,3 +410,5 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod ) } + +export default MoveDoorTool diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/nodes/src/door/panel.tsx similarity index 92% rename from packages/editor/src/components/ui/panels/door-panel.tsx rename to packages/nodes/src/door/panel.tsx index c46838de..c6f82e4e 100644 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -8,21 +8,23 @@ import { useInteractive, useScene, } from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + cn, + PanelSection, + PanelWrapper, + PresetsPopover, + SegmentedControl, + SliderControl, + ToggleControl, + triggerSFX, + useEditor, + usePresetsAdapter, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' -import { usePresetsAdapter } from '../../../contexts/presets-context' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { cn } from '../../../lib/utils' -import useEditor from '../../../store/use-editor' -import { ActionButton, ActionGroup } from '../controls/action-button' -import { MetricControl } from '../controls/metric-control' -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' -import { PresetsPopover } from './presets/presets-popover' const doorTypeOptions = [ { label: 'Hinged', value: 'hinged', available: true }, @@ -106,10 +108,9 @@ function isSameDoorValue(current: unknown, next: unknown): boolean { return Object.is(current, next) } -export function DoorPanel() { +export default function DoorPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) - const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const previewRef = useRef<{ @@ -124,9 +125,11 @@ export function DoorPanel() { selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined, ) + // Panel slider-drag fix recipe (plans/editor-node-registry.md). Without + // it, the 29+ SliderControls in this panel would loop on drag. const handleUpdate = useCallback( (updates: Partial) => { - if (!(selectedId && node)) return + if (!selectedId) return const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] if (liveNode?.type !== 'door') return @@ -139,12 +142,12 @@ export function DoorPanel() { if ('operationState' in updates || 'swingAngle' in updates || 'doorType' in updates) { useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId) } - updateNode(selectedId as AnyNode['id'], updates) + useScene.getState().updateNode(selectedId as AnyNode['id'], updates) const scene = useScene.getState() scene.dirtyNodes.add(selectedId as AnyNodeId) if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) }, - [selectedId, node, updateNode], + [selectedId], ) const previewDoorUpdate = useCallback( @@ -188,10 +191,12 @@ export function DoorPanel() { } previewRef.current = null - updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) + useScene + .getState() + .updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) scene.dirtyNodes.add(selectedId as AnyNodeId) }, - [selectedId, updateNode], + [selectedId], ) const handleClose = useCallback(() => { @@ -208,14 +213,14 @@ export function DoorPanel() { const handleMove = useCallback(() => { if (!node) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) const handleDelete = useCallback(() => { if (!(selectedId && node)) return - sfxEmitter.emit('sfx:item-delete') + triggerSFX('sfx:item-delete') deleteNode(selectedId as AnyNode['id']) if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) setSelection({ selectedIds: [] }) @@ -223,7 +228,7 @@ export function DoorPanel() { const handleDuplicate = useCallback(() => { if (!node?.parentId) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') useScene.temporal.getState().pause() const cloned = structuredClone(node) as any delete cloned.id @@ -984,75 +989,75 @@ export function DoorPanel() { /> - {!isGarageDoor && ( - - handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} - precision={3} - step={0.005} - unit="m" - value={Math.round(node.contentPadding[0] * 1000) / 1000} - /> - handleUpdate({ contentPadding: [node.contentPadding[0], v] })} - precision={3} - step={0.005} - unit="m" - value={Math.round(node.contentPadding[1] * 1000) / 1000} - /> - - )} - - {isSwingDoor && ( - -
- {supportsHingeSide && ( -
- - Hinges Side - - handleUpdate({ hingesSide: v })} - options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, - ]} - value={node.hingesSide} - /> -
- )} -
- - Direction - - handleUpdate({ swingDirection: v })} - options={[ - { label: 'Inward', value: 'inward' }, - { label: 'Outward', value: 'outward' }, - ]} - value={node.swingDirection} + {!isGarageDoor && ( + + handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} + precision={3} + step={0.005} + unit="m" + value={Math.round(node.contentPadding[0] * 1000) / 1000} /> -
-
-
- )} + handleUpdate({ contentPadding: [node.contentPadding[0], v] })} + precision={3} + step={0.005} + unit="m" + value={Math.round(node.contentPadding[1] * 1000) / 1000} + /> + + )} - {isSwingDoor && ( - - handleUpdate({ threshold: checked })} - /> - {node.threshold && ( -
+ {isSwingDoor && ( + +
+ {supportsHingeSide && ( +
+ + Hinges Side + + handleUpdate({ hingesSide: v })} + options={[ + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + ]} + value={node.hingesSide} + /> +
+ )} +
+ + Direction + + handleUpdate({ swingDirection: v })} + options={[ + { label: 'Inward', value: 'inward' }, + { label: 'Outward', value: 'outward' }, + ]} + value={node.swingDirection} + /> +
+
+
+ )} + + {isSwingDoor && ( + + handleUpdate({ threshold: checked })} + /> + {node.threshold && ( +
= { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.5, max: 6, step: 0.05 }, + { key: 'height', kind: 'number', unit: 'm', min: 1.0, max: 4, step: 0.05 }, + ], + }, + { + label: 'Frame', + fields: [ + { key: 'frameThickness', kind: 'number', unit: 'm', min: 0.01, max: 0.2, step: 0.005 }, + { key: 'frameDepth', kind: 'number', unit: 'm', min: 0.01, max: 0.3, step: 0.005 }, + ], + }, + ], + customPanel: () => import('./panel'), +} diff --git a/packages/viewer/src/components/renderers/door/door-renderer.tsx b/packages/nodes/src/door/renderer.tsx similarity index 90% rename from packages/viewer/src/components/renderers/door/door-renderer.tsx rename to packages/nodes/src/door/renderer.tsx index 488ee108..17cb3c3e 100644 --- a/packages/viewer/src/components/renderers/door/door-renderer.tsx +++ b/packages/nodes/src/door/renderer.tsx @@ -1,7 +1,9 @@ +'use client' + import { type DoorNode, useRegistry, useScene } from '@pascal-app/core' +import { useNodeEvents } from '@pascal-app/viewer' import { useLayoutEffect, useRef } from 'react' import { type Mesh, MeshBasicMaterial } from 'three' -import { useNodeEvents } from '../../../hooks/use-node-events' const doorHitboxMaterial = new MeshBasicMaterial({ visible: false }) @@ -30,3 +32,5 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { ) } + +export default DoorRenderer diff --git a/packages/nodes/src/door/schema.ts b/packages/nodes/src/door/schema.ts new file mode 100644 index 00000000..1f4fac97 --- /dev/null +++ b/packages/nodes/src/door/schema.ts @@ -0,0 +1 @@ +export { DoorNode } from '@pascal-app/core' diff --git a/packages/nodes/src/door/system.tsx b/packages/nodes/src/door/system.tsx new file mode 100644 index 00000000..a8abd140 --- /dev/null +++ b/packages/nodes/src/door/system.tsx @@ -0,0 +1,29 @@ +'use client' + +import { DoorAnimationSystem, DoorSystem } from '@pascal-app/viewer' + +/** + * Registry-driven door system bundle. + * + * - **`DoorSystem`** — rebuilds frame / leaf / glass / hardware + * geometry from `dirtyNodes`. Cascades dirty to the parent wall so + * the wall cutout reflects the new door footprint. + * - **`DoorAnimationSystem`** — advances `operationState` (open/close + * angle for hinged, slide offset for sliding/pocket, fold angle for + * folding) at frame priority 2, then marks the door dirty so the + * geometry system rebuilds at priority 3. + * + * Future: extract the geometry into a pure `buildDoorGeometry(node, ctx)` + * and migrate to `def.geometry`. The animation system stays as + * `def.system` (it's a real per-frame concern, not a geometry build). + */ +const DoorSystems = () => { + return ( + <> + + + + ) +} + +export default DoorSystems diff --git a/packages/editor/src/components/tools/door/door-tool.tsx b/packages/nodes/src/door/tool.tsx similarity index 97% rename from packages/editor/src/components/tools/door/door-tool.tsx rename to packages/nodes/src/door/tool.tsx index 76da29e7..69c0ce87 100644 --- a/packages/editor/src/components/tools/door/door-tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -8,19 +8,19 @@ import { useScene, type WallEvent, } from '@pascal-app/core' +import { + calculateCursorRotation, + calculateItemRotation, + EDITOR_LAYER, + getSideFromNormal, + isValidWallSideFace, + snapToHalf, + triggerSFX, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { - calculateCursorRotation, - calculateItemRotation, - getSideFromNormal, - isValidWallSideFace, - snapToHalf, -} from '../item/placement-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' const edgeMaterial = new LineBasicNodeMaterial({ @@ -34,7 +34,7 @@ const edgeMaterial = new LineBasicNodeMaterial({ * Door tool — places DoorNodes on walls only. * Doors always sit at floor level (clampedY = height/2). */ -export const DoorTool: React.FC = () => { +const DoorTool: React.FC = () => { const draftRef = useRef(null) const cursorGroupRef = useRef(null!) const edgesRef = useRef(null!) @@ -273,7 +273,7 @@ export const DoorTool: React.FC = () => { useScene.getState().createNode(node, event.node.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) useScene.temporal.getState().pause() - sfxEmitter.emit('sfx:item-place') + triggerSFX('sfx:item-place') event.stopPropagation() } @@ -322,3 +322,5 @@ export const DoorTool: React.FC = () => { ) } + +export default DoorTool diff --git a/packages/nodes/src/elevator/definition.ts b/packages/nodes/src/elevator/definition.ts new file mode 100644 index 00000000..7ca27966 --- /dev/null +++ b/packages/nodes/src/elevator/definition.ts @@ -0,0 +1,54 @@ +import { ElevatorNode as ElevatorNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { buildElevatorFloorplan } from './floorplan' +import { elevatorParametrics } from './parametrics' +import { ElevatorNode } from './schema' + +/** + * Elevator — Stage A registration. Wrap-exports the legacy renderer + + * the three legacy systems (runtime / interaction / opening) bundled + * as one `def.system`. Move / inspector still go through legacy + * (`MoveElevatorTool`, ``) via panel-manager's + * hardcoded switch. + */ +export const elevatorDefinition: NodeDefinition = { + kind: 'elevator', + schemaVersion: 1, + schema: ElevatorNode, + category: 'structure', + + defaults: () => { + const stub = ElevatorNodeSchema.parse({ id: 'elevator_default' as never, type: 'elevator' }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: elevatorParametrics, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + system: { + module: () => import('./system'), + priority: 3, + }, + floorplan: buildElevatorFloorplan, + + presentation: { + label: 'Elevator', + description: 'A multi-level elevator shaft with configurable openings per level.', + icon: { kind: 'url', src: '/icons/wallcut.png' }, + paletteSection: 'structure', + paletteOrder: 80, + }, + + mcp: { + description: 'A multi-level elevator with shaft + openings per level.', + }, +} diff --git a/packages/nodes/src/elevator/floorplan.ts b/packages/nodes/src/elevator/floorplan.ts new file mode 100644 index 00000000..8fdfa492 --- /dev/null +++ b/packages/nodes/src/elevator/floorplan.ts @@ -0,0 +1,313 @@ +import { + type AnyNodeId, + type ElevatorNode, + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + resolveElevatorServiceLevelIds, + useInteractive, + useLiveNodeOverrides, +} from '@pascal-app/core' + +/** + * Stage C floor-plan emitter for elevator. Renders: + * + * - **Outer shaft footprint** — rotated rectangle (cab + wall thickness). + * - **Cab indicator** — inner rectangle showing the cab's position within + * the shaft. Highlighted when `runtime.currentLevelId` matches the + * active level (i.e. the car is *on this floor*). + * - **Door opening indicator** — a short marker on the front face + * spanning `doorWidth` so users can see which way the doors open. + * - **Selection / target / queued chrome** — selection stroke when + * the elevator is selected, accent stroke when the runtime targets + * this level (cab is travelling here) or this level is queued. + * + * Reads the elevator's live state via `useLiveNodeOverrides.getState()` + * (inspector edits) and `useInteractive.getState().elevators[id]` + * (runtime cab travel). Those reads are non-reactive on their own — + * `FloorplanRegistryLayer` subscribes to both stores so the layer + * re-renders when they change, propagating into this builder. + * + * Per-level served-level chips (the small floor-label badges on each + * shaft side) are not emitted yet — they need an HTML-overlay primitive + * in `FloorplanGeometry` to render properly (SVG `` rotates with + * the plan, which mangles label legibility). Tracked as follow-up; the + * legacy `` still renders the chips for + * pre-registry builds while we figure out the right primitive shape. + */ + +const STAGE_LEVEL_FILTER_HIDE = true + +export function buildElevatorFloorplan( + node: ElevatorNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + // Merge in any live overrides (inspector edits not yet committed). + const overrides = useLiveNodeOverrides.getState().get(node.id) + const display: ElevatorNode = overrides ? ({ ...node, ...overrides } as ElevatorNode) : node + + // Service-level gate. If the active level isn't one the elevator + // serves, render nothing — legacy behaviour. The level id comes via + // `ctx.parent` (the elevator's parent in the tree is the level it's + // hosted on, which is the active level when the registry layer walks + // from `levelId`). + const parentLevelId = ctx.parent?.id + if (STAGE_LEVEL_FILTER_HIDE && parentLevelId) { + const sceneNodes = collectAllNodes(ctx) + const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes) + if (!serviceLevelIds.includes(parentLevelId as AnyNodeId)) { + return null + } + } + + const wallThickness = Math.max(display.shaftWallThickness ?? 0.09, 0.04) + const cabWidth = Math.max(display.width, 0.8) + const cabDepth = Math.max(display.depth, 0.8) + const shaftWidth = Math.max(display.shaftWidth ?? display.width, cabWidth, 0.8) + const shaftDepth = Math.max(display.shaftDepth ?? display.depth, cabDepth, 0.8) + const doorWidth = Math.min(Math.max(display.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18) + const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness) + const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness) + + const center = { x: display.position[0], y: display.position[2] } + const cos = Math.cos(display.rotation) + const sin = Math.sin(display.rotation) + const rotate = (lx: number, ly: number): [number, number] => { + // Same clockwise convention as `rotatePlanVector` in editor — see + // `wiki/architecture/tools.md` for why every plan-space rotation + // uses this matrix and not the standard counter-clockwise one. + return [lx * cos + ly * sin, -lx * sin + ly * cos] + } + + // Outer shaft footprint corners. + const outerCorners: Array = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + const outerPoints: FloorplanPoint[] = outerCorners.map(([lx, ly]) => { + const [rx, ry] = rotate(lx, ly) + return [center.x + rx, center.y + ry] + }) + + // Cab inner rectangle. The cab sits flush against the front face + // (-Z in local coords) so its center is `-shaftDepth/2 + cabDepth/2` + // away from shaft center. + const cabCenterLocalY = -shaftDepth / 2 + cabDepth / 2 + const cabHalfW = cabWidth / 2 + const cabHalfD = cabDepth / 2 + const cabCorners: Array = [ + [-cabHalfW, cabCenterLocalY - cabHalfD], + [cabHalfW, cabCenterLocalY - cabHalfD], + [cabHalfW, cabCenterLocalY + cabHalfD], + [-cabHalfW, cabCenterLocalY + cabHalfD], + ] + const cabPoints: FloorplanPoint[] = cabCorners.map(([lx, ly]) => { + const [rx, ry] = rotate(lx, ly) + return [center.x + rx, center.y + ry] + }) + + // Runtime state — current level / target level / queued. + const runtime = useInteractive.getState().elevators[node.id] + const isCarOnLevel = parentLevelId ? runtime?.currentLevelId === parentLevelId : false + const isTargetLevel = parentLevelId ? runtime?.targetLevelId === parentLevelId : false + const isQueuedLevel = parentLevelId + ? (runtime?.queue.includes(parentLevelId as never) ?? false) + : false + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Stroke selection — selected wins, then runtime target / queued + // states get the accent palette colour so users can spot "the cab is + // coming here" at a glance. + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isTargetLevel || isQueuedLevel + ? '#0ea5e9' + : '#475569' + // Shaft fill — orange when selected, light slate otherwise. When the + // car is *on this level*, the cab indicator inside gets the highlight + // instead of the whole shaft (more legible). + const shaftFill = showSelectedChrome ? '#fed7aa' : '#cbd5e1' + const cabFill = isCarOnLevel ? '#22c55e' : showSelectedChrome ? '#fef3c7' : '#e2e8f0' + const cabStroke = isCarOnLevel ? '#15803d' : '#475569' + + const children: FloorplanGeometry[] = [] + + // Outer shaft. + children.push({ + kind: 'polygon', + points: outerPoints, + fill: shaftFill, + stroke, + strokeWidth: showSelectedChrome ? 0.04 : 0.03, + strokeLinejoin: 'round', + opacity: 0.85, + }) + + // Cab inner rectangle. + children.push({ + kind: 'polygon', + points: cabPoints, + fill: cabFill, + fillOpacity: isCarOnLevel ? 0.85 : 0.55, + stroke: cabStroke, + strokeWidth: 0.018, + strokeLinejoin: 'round', + opacity: 0.92, + }) + + // Door opening indicator — a short line on the front edge centered + // on the cab. The legacy renders a more complex slide / center-open + // hint; this is the minimum useful signal. + const doorY = -halfDepth + const [doorStartX, doorStartY] = rotate(-doorWidth / 2, doorY) + const [doorEndX, doorEndY] = rotate(doorWidth / 2, doorY) + children.push({ + kind: 'line', + x1: center.x + doorStartX, + y1: center.y + doorStartY, + x2: center.x + doorEndX, + y2: center.y + doorEndY, + stroke: isCarOnLevel ? '#15803d' : '#0f172a', + strokeWidth: 0.05, + strokeLinecap: 'round', + opacity: 0.92, + }) + + // Served-level chips — vertical column of marker circles + level + // numbers to the right of the shaft, only when selected and the + // elevator serves more than one level. Mirrors the legacy + // `` chip rendering (~line 6423 in + // floorplan-panel.tsx). + if (isSelected && parentLevelId) { + const sceneNodes = collectAllNodes(ctx) + const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes) + if (serviceLevelIds.length > 1) { + const disabledLevelIds = new Set(display.disabledLevelIds ?? []) + const serviceOnlyLevelIds = new Set(display.serviceOnlyLevelIds ?? []) + const rangeStep = 0.18 + const rangeHeight = Math.max(0, (serviceLevelIds.length - 1) * rangeStep) + const [rangeOffsetX, rangeOffsetY] = rotate(halfWidth + 0.38, 0) + const rangeX = center.x + rangeOffsetX + const rangeBottomY = center.y + rangeOffsetY + rangeHeight / 2 + const rangeTopY = center.y + rangeOffsetY - rangeHeight / 2 + + // Connector spine — single vertical line tying the chips to the + // shaft. Sky blue, semi-transparent. + children.push({ + kind: 'line', + x1: rangeX, + y1: rangeTopY, + x2: rangeX, + y2: rangeBottomY, + stroke: '#0ea5e9', + strokeOpacity: 0.52, + strokeWidth: 0.018, + strokeLinecap: 'round', + vectorEffect: 'non-scaling-stroke', + }) + + // One chip per served level. Lowest level at the bottom of the + // column, index increases upward — matches legacy ordering. + serviceLevelIds.forEach((levelId, index) => { + const isCurrent = runtime?.currentLevelId === levelId + const isTarget = runtime?.targetLevelId === levelId + // `resolveElevatorServiceLevelIds` returns plain `string[]`, but + // the runtime queue is `AnyNodeId[]` (branded). The values agree + // at runtime — narrowing through `as never` keeps the includes + // call type-safe without dragging the brand into the helper's + // public return type. + const isQueued = runtime?.queue.includes(levelId as never) ?? false + const isDisabled = disabledLevelIds.has(levelId) + const isServiceOnly = serviceOnlyLevelIds.has(levelId) + const isUnavailable = isDisabled || isServiceOnly + + const markerFill = isCurrent + ? '#22c55e' + : isTarget || isQueued + ? '#38bdf8' + : isUnavailable + ? '#94a3b8' + : '#ffffff' + const markerStroke = isUnavailable ? '#64748b' : '#0369a1' + const labelColor = isUnavailable ? '#64748b' : '#075985' + const y = rangeBottomY - index * rangeStep + + children.push({ + kind: 'circle', + cx: rangeX, + cy: y, + r: 0.055, + fill: markerFill, + fillOpacity: isUnavailable ? 0.72 : 0.95, + stroke: markerStroke, + strokeWidth: 0.012, + }) + children.push({ + kind: 'text', + x: rangeX + 0.11, + y, + text: String(index + 1), + fontSize: 0.13, + fontWeight: 700, + fill: labelColor, + textAnchor: 'start', + dominantBaseline: 'middle', + }) + }) + } + } + + if (isSelected) { + children.push({ + kind: 'move-handle', + point: [display.position[0], display.position[2]], + }) + } + + return { kind: 'group', children } +} + +/** + * `ctx` exposes `resolve` and `children` / `siblings` / `parent`, but + * not the full nodes map. `resolveElevatorServiceLevelIds` wants a + * `Record`; we rebuild it by walking the chain we DO have + * access to. For the elevator's service-level check we only need the + * elevator's parent (the level), its building, and any level siblings. + * This is the minimum graph the resolver needs. + * + * If a future use needs the full nodes map for a builder, we'd surface + * it through ctx — but doing so leaks the whole scene store into every + * `def.floorplan` call. Narrow opt-in is the better default. + */ +function collectAllNodes(ctx: GeometryContext): Record { + // We need the building → levels graph for service-level resolution. + // Walk up from the elevator: parent (level) → its parent (building) → + // building.children (all levels). That's enough for the resolver. + const out: Record = {} + const level = ctx.parent + if (level) { + out[level.id] = level + const building = (level as { parentId?: string }).parentId + ? ctx.resolve((level as { parentId: string }).parentId as never) + : undefined + if (building) { + out[building.id] = building + const childIds = (building as unknown as { children?: string[] }).children + if (Array.isArray(childIds)) { + for (const cid of childIds) { + const child = ctx.resolve(cid as never) + if (child) out[child.id] = child + } + } + } + } + return out as Record +} diff --git a/packages/nodes/src/elevator/index.ts b/packages/nodes/src/elevator/index.ts new file mode 100644 index 00000000..3ea05436 --- /dev/null +++ b/packages/nodes/src/elevator/index.ts @@ -0,0 +1 @@ +export { elevatorDefinition } from './definition' diff --git a/packages/editor/src/components/ui/panels/elevator-panel.tsx b/packages/nodes/src/elevator/panel.tsx similarity index 97% rename from packages/editor/src/components/ui/panels/elevator-panel.tsx rename to packages/nodes/src/elevator/panel.tsx index 02bbc4aa..47c254b0 100644 --- a/packages/editor/src/components/ui/panels/elevator-panel.tsx +++ b/packages/nodes/src/elevator/panel.tsx @@ -12,18 +12,22 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + MetricControl, + PanelSection, + PanelWrapper, + resolveElevatorNodeSupportY, + resolveElevatorSupportY, + SliderControl, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Send, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useShallow } from 'zustand/react/shallow' -import { resolveElevatorNodeSupportY, resolveElevatorSupportY } from '../../../lib/elevator-support' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { ActionButton, ActionGroup } from '../controls/action-button' -import { MetricControl } from '../controls/metric-control' -import { PanelSection } from '../controls/panel-section' -import { SliderControl } from '../controls/slider-control' -import { PanelWrapper } from './panel-wrapper' function findLevelId(levels: LevelNode[], levelId: string | null | undefined) { if (!levelId) return null @@ -153,7 +157,7 @@ function degreesToRadians(degrees: number) { return (degrees * Math.PI) / 180 } -export function ElevatorPanel() { +export default function ElevatorPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) @@ -302,7 +306,7 @@ export function ElevatorPanel() { const handleMove = useCallback(() => { if (!node) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') clearLivePreview() setMovingNode(node) setSelection({ selectedIds: [] }) @@ -310,7 +314,7 @@ export function ElevatorPanel() { const handleDuplicate = useCallback(() => { if (!(node && node.parentId)) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') const duplicate = ElevatorNodeSchema.parse({ ...structuredClone(node), @@ -328,7 +332,7 @@ export function ElevatorPanel() { const handleDelete = useCallback(() => { if (!(selectedId && node)) return - sfxEmitter.emit('sfx:structure-delete') + triggerSFX('sfx:structure-delete') clearLivePreview() useScene.getState().deleteNode(selectedId as AnyNodeId) setSelection({ selectedIds: [] }) @@ -442,7 +446,11 @@ export function ElevatorPanel() { ) const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id)) const defaultLevelOptions = - enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels + enabledServedLevels.length > 0 + ? enabledServedLevels + : servedLevels.length > 0 + ? servedLevels + : levels const selectedDefaultLevelId = defaultLevelOptions.some( (level) => level.id === node.defaultLevelId, ) @@ -570,14 +578,14 @@ export function ElevatorPanel() { { - sfxEmitter.emit('sfx:item-rotate') + triggerSFX('sfx:item-rotate') commitTransform(displayPosition, displayRotation - Math.PI / 4) }} /> { - sfxEmitter.emit('sfx:item-rotate') + triggerSFX('sfx:item-rotate') commitTransform(displayPosition, displayRotation + Math.PI / 4) }} /> @@ -753,9 +761,7 @@ export function ElevatorPanel() {