editor: in-world selection UI across wall / door / window / stair (#334)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * wall: 2D floor-plan move, side arrows, drag-to-move endpoints Bundles the in-progress wall editing work on this branch: - Wall corner endpoint drag in 3D (`floating-action-menu.tsx`, `wall/move-endpoint-tool.tsx`): press-and-drag on the floating endpoint button or the new 3D corner sphere, release to commit. Replaces the prior click-to-arm / click-to-place flow. - New 2D move side arrows on selected walls via a new `move-arrow` floor-plan geometry kind (core type + registry-layer renderer + wall floor-plan builder emission), mirroring the 3D `WallMoveSideHandles`. - 2D wall body move: new `wallFloorplanMoveTarget` translates the moving wall and cascades shared endpoints onto linked walls so L-corners stay connected through the drag. - `MoveWallTool` cleanup gains an external-commit guard so a 2D commit doesn't get clobbered by the 3D mover's cleanup restore. - HMR-safe `bootstrap.ts` no longer re-registers builtin kinds whose registry entry survived the closure reset. - Misc 2D polish: floor-plan auto-fit measures the painted scene via `getBBox`, wall dimension offset bumped, swallow-click guard in `handleSelect` so registry-driven selection holds through the post-pointerdown re-render. Floor-plan move-target / move-arrow code still carries diagnostic console logs for the cascade flow; keeping for debug on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wall: 2D floor-plan move adopts 3D junction planner 2D wall drag now produces the same scene topology as 3D — linked corners cascade per `planWallMoveJunctions`, off-axis branches stay rectilinear with a bridge wall inserted between the original and new corner, and same-direction consumed walls collapse and delete. Previously the 2D handler did a naive endpoint-stretch cascade with no bridges or collapses, so dragging an L-corner in 2D vs 3D yielded different scenes. `FloorplanMoveTargetSession` gains an optional `commit` hook. The default overlay path snapshots affected nodes and writes a diff back on release — fine for kinds whose commit is a pure position update, but insufficient when commit needs to also create or delete nodes. When `commit` is present, the overlay reverts to baseline, resumes history, and delegates the atomic write; one Ctrl-Z rolls back the entire operation including bridge creates and collapsed deletes. Shared helpers (`planWallMoveJunctions` plan → updates, linked-wall snapshots, bridge synthesis) lifted to a new `packages/nodes/src/wall/ move-shared.ts` so both the 3D `MoveWallTool` and the 2D `wallFloorplanMoveTarget` import them. Net -163 LoC after dedup. Auto-slab live preview and ghost bridge previews mid-drag — visible in 3D today — remain 3D-only; 2D surfaces them at commit time through the normal scene reactions. Tracked as follow-up. Also drops three `// temp diagnostic` console.log blocks left over from the prior wall-move branch (2D setup, 2D canCommit, 3D cleanup). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * door/window: R flips side, E toggles open/close R previously toggled the open/closed state of operable doors and operable windows. It now flips the opening's side (front ↔ back, rotation += π) for both — same gesture as flipping a furniture item that knows about handedness. The open/close toggle moved to E, which was unbound for doors and windows before. T is now a no-op on doors and windows so it doesn't free-rotate a wall-bound node by π/4 (which made no architectural sense). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * door/window: keep 2D plan in sync during placement drag While drafting a door or window across the same host wall, the tool was bypassing the scene store and mutating the Three.js mesh directly. That kept 3D snappy but left the 2D floor plan reading the last committed position — drafts froze in place on the 2D side during a same-wall drag. Route same-wall moves back through \`updateNode\` so 2D and 3D both re-render from a single source. The reparent path (cross-wall drag) still uses \`updateNode\` with \`parentId\` and \`wallId\` — we only avoid forwarding those fields when the wall hasn't changed so the host wall's \`children\` array doesn't churn each tick and trigger a WebGPU "Vertex buffer slot 0 ... was not set" warning from the briefly re-rendered placeholder geometry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * floor-plan: live wall-draft measurement + opening placement event order Two changes to the floor-plan panel: 1. Length + angle labels render alongside the wall draft in 2D, matching the 3D \`WallTool\` feedback. Length sits at the segment midpoint with a plate that flips when its on-screen orientation would read upside-down; angle arcs anchor at each endpoint that meets an existing wall and label the deviation from that wall's direction. 2. The pointer-move handler ran the registry catch-all (\`isFloorplanGridInteractionActive\`) before the opening-placement branch. Door and window are registered kinds, so during their build mode the catch-all emitted \`grid:move\` and returned — starving the \`wall:enter\` / \`wall:move\` events the placement tools listen for. Reorder so opening placement runs first; the wall-build skip in the catch-all is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wall: pass geometry as prop on move arrow handles R3F's \`<primitive attach="geometry">\` path emits a \`Draw(0, 1, 0, 0)\` on the first frame because the host \`<mesh>\` briefly renders with the default empty \`BufferGeometry\` before the primitive child attaches. Combined with \`frustumCulled={false}\`, WebGPU flagged "Vertex buffer slot 0 ... was not set" every time a wall or fence was selected and the move arrows mounted. Pass \`arrowGeometry\` as a prop on the \`<mesh>\` so it's never mounted with the default placeholder. Same fix applied to both the wall and fence move-arrow handles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ifc-converter: regenerate next-env.d.ts after Next route path move Next.js moved the generated routes typings from \`./.next/dev/types/routes.d.ts\` to \`./.next/types/routes.d.ts\` in the current version pinned by the workspace. Regenerated via \`next typegen\` so the project compiles against the right path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * move: track which view finalised so split-view cleanups don't race In split view, both the 2D move overlay and the 3D move tool mount for the same \`movingNode\` and each captures its own pre-drag snapshot. When one side finalises (commit or Esc), the other side unmounts because \`setMovingNode(null)\` propagates — and its effect cleanup had to *guess* whether the live scene was already-committed state (skip restore) or its own drag's uncommitted state (revert). Both cleanups did this via the same heuristic: diff snapshot fields against current scene state. Cheap, but it conflates "the other side committed" with "the user's apply() actually changed something" — and fails outright if a commit happens to land on the same numeric values as the snapshot. Replace the heuristic with an explicit \`movingNodeOrigin\` state field: '2d' | '3d' | null. The finalising side sets its origin before \`setMovingNode(null)\` runs; the other side's cleanup reads it. \`movingNodeOrigin\` is preserved across \`setMovingNode(null)\` (so it's still observable when the cleanup fires) and reset the next time a non-null \`setMovingNode\` starts a fresh drag. Wired on the wall move-tool (3D) and \`FloorplanRegistryMoveOverlay\` (2D) — the two real call sites today. Other 3D move tools can adopt the same flag incrementally as their own split-view races surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wall: 2D drag publishes to useLiveNodeOverrides, zustand only on commit Side-arrow / corner-dot / curve-handle drags in the 2D floor plan now publish `{ start, end, curveOffset }` to `useLiveNodeOverrides` each tick instead of writing to `useScene`. WallSystem, the 2D registry layer, and the wall sidebar all merge the overrides in when reading endpoints, so the visual + slider preview tracks the cursor while zustand stays at the pre-drag values until pointer-up. Commit writes one tracked `applyNodeChanges` (junction-aware) and clears the overrides; Esc / pointercancel / mid-drag unmount also clear them. Also bundles the in-progress branch work this depends on: - FloorplanAffordanceSession gains optional `commit?()` mirror of the move-target hook; the dispatcher reverts → resumes → calls it when present (vs. its default snapshot-diff dance). - Selected wall body is now pointer-events-inert (polygon `pointerEvents: 'none'` + hit-line skipped) so only the arrows / endpoint dots / curve dot start a drag. - Move button removed from the 2D floating action menu and the wall sidebar inspector for walls — redundant with the side-arrows. - `useWallMoveGhosts` store + `FloorplanWallMoveGhostLayer` for the dashed bridge previews painted mid-drag. - WebGPU "Vertex buffer slot 0 ... was not set" fixes on grid + guide renderer + wall draft preview by passing geometry as a prop (same pattern as wall-move-side-handles). - Floor-plan wall-tool fallback: when the 3D wall tool's `grid:click` already committed the wall, treat `createWallOnCurrentLevel` returning null as "the 3D side handled it" and chain the next draft segment instead of clearing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wall: new in-world selection UI — side arrows, height handle, corner pickers, ground menu 3D affordances for a selected wall, replacing the HTML floating pill: - side move arrows: thinner chevron+shaft silhouette (extruded, beveled); press-hold-drag-release commits on pointerup (MoveWallTool no longer uses grid:click) - height arrow above the wall midpoint, drags vertically against a camera-facing plane and updates wall.height live; new resizingWallHeight state gates camera orbit; commit plays sfx:item-place - corner picker per endpoint: billboarded hex disc at floor + dashed vertical leader cylinder; pointerdown routes to the existing movingWallEndpoint flow (works for 2D and 3D) - ground action menu (curve / duplicate / delete): three Lucide SVGs rendered as canvas-textured planes lying flat on the floor, anchored one wall thickness + clearance outside the camera-facing face; one rigid container moves them as a unit (auto-flips sides + rotates with the wall, on curved walls uses the t=0.5 curve frame) - floating action menu hidden for walls (replaced by the above) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wall: smooth ground-menu side-flip with hysteresis + lerp The three floor icons appeared to "move one at a time" when orbiting: binary side decision flickered on grazing orbits, and the 180° rotation flip swapped curve/delete across each other while duplicate (offset 0) stayed put. Now lerps position+rotation toward target with a hysteresis dead-zone, so the menu swings around the wall as one unit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wall: raise ground-action menu to 10 cm so icons clear floor textures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wall: anchor 2D action menu and 3D height arrow at curve apex 2D menu centres on getWallMidpointHandlePoint and stays horizontal 32 px above the wall; 3D height arrow uses getWallCurveFrameAt(0.5) so the apex+tangent match the side handles on curved walls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * door: in-world selection UI — side width arrows, height arrow, ground menu Side arrows resize width anchored at the opposite edge; top arrow drags height anchored at the floor. Ground menu mirrors the wall pattern with move + duplicate + delete icons that flip to the camera side. Handles portal into the level (not the wall mesh) and wrap in a per-frame transform mirror so wall hover outline doesn't pick them up. New viewer flag handleDragging gates node pointer events during in-world drags; pointerup also swallows the follow-up synthetic click so the PointerMissedHandler doesn't deselect the active item on commit. Wall height arrow, wall move arrow, and fence move arrow all opt in. Scale chevron arrows down to 65 % across wall + door so the family reads as one. Panel type grids (door, window, column, skylight) get matched breathing room (px-3 py-2.5, gap-2) so labels stop hugging the borders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * door: 2D width arrows + world-relative move dot, deterministic commits Side-arrow width drag in the 2D floor plan: doors now emit two width arrows at the wall-tangent edges when selected, routed through a new `resize-width` affordance that anchors at the opposite edge, clamps to wall bounds, and previews per-tick via scene writes so both the floor plan and the 3D viewer track the drag in real time. `move-arrow` kind gains optional `affordance` + `payload` so the same chevron primitive can route to either the move flow (walls) or an arbitrary affordance (door width-resize) without forking the renderer. Move-dot for the door is now world-anchored — it scales with zoom in place of the previous screen-constant size, matching the rest of the door's chrome. Both `doorWidthAffordance.commit()` and `doorFloorplanMoveTarget.commit()` own their atomic final write so the dispatchers take the deterministic revert → resume → commit path. The diff path was silently reverting when the post-apply state happened to match the snapshot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * window: in-world selection UI, 2D width arrows, deterministic commits Bring window 3D + 2D selection chrome to parity with door. Selecting a window in 3D now emits two side width arrows, top + bottom height arrows (top anchors at the sill, bottom anchors at the lintel and clamps to the wall floor), and an in-world action menu that rides just below the bottom arrow's tip so the column moves with the sill. 2D plan adds two `resize-width` arrows at the start / end edges, routed through the new `windowWidthAffordance` — same anchored-edge + wall-bounds clamp + per-tick scene-write preview the door uses. `windowFloorplanMoveTarget.commit()` is now self-owned: `apply()` snapshots the last valid placement and `commit()` re-applies it, so the dispatcher takes the deterministic revert → resume → commit path instead of the diff path that silently reverts when the post-apply state happens to match the snapshot. Mirrors the door fix. The HTML floating-action-menu skips windows now that the in-world ground menu owns those actions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * stair: in-world selection UI — side/length/height arrows, ground menu Bring stair-segment selection chrome to parity with wall / door / window. Selecting a stair segment in 3D now emits two side width arrows (each slides the opposite edge anchor under the user), a length arrow at the back face that extends the run, and — for stair-type segments — a height arrow on top. A ground action menu (duplicate / delete) sits beside the segment and flips sides as the camera orbits, with hysteresis + lerp so it doesn't dither. The handles portal into the stair's PARENT object (level / building / scene root) rather than the stair group itself: StairRenderer attaches `useNodeEvents` to the stair group, so any descendant pointer-over would bubble up and set `hoveredId = stairId`, which then makes the post-processing outline traverse the entire stair group and stroke our icons. Mirrors the door fix. A two-layer transform mirror (`stairPoseRef` + `segmentPoseRef`) keeps the handles aligned with the chained per-segment pose that StairSystem writes imperatively each frame. Duplicate forces `attachmentSide: 'front'` on the clone so it continues the chain end cleanly instead of inheriting the original's side and U-turning. New `resizingStairSegment{Width,Length,Height}` editor state lets `CustomCameraControls` suppress orbit/zoom while an arrow is dragging, matching the wall/door/window handle pattern. The HTML floating-action-menu skips stair-segments now that the in-world ground menu owns those actions. Stair-segment panel swaps its bespoke fill-to-floor toggle for the shared `ToggleControl` so it looks like the other panels and groups with the thickness slider under one `space-y-3` block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * stair: 2D move-revert fix, parent ground menu, curved/spiral in-world arrows - Route stair 2D moves through `floorplanMoveTarget` and honor `movingNodeOrigin === '2d'` in `MoveRoofTool` cleanup so the 3D tool's restore-from-snapshot no longer stomps the 2D commit. - Parent stair selection shows an in-world ground action menu (move / duplicate / delete) anchored beside the stair; the screen-space floating menu is suppressed for `type === 'stair'` to match door / window / segment. - Curved & spiral stairs gain in-world resize arrows: rise (centered on the pillar for spirals), width, inner radius, and two sweep handles (one per arc end) clustered beside the width arrow. - Camera controls pause during curved-stair drags. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: reuse measurement-bar geometry; fix two post-merge dangling refs MeasurementBar was building a fresh BoxGeometry per render for every wall measurement bar, which the WebGPU backend flagged ("Vertex buffer slot N ... was not set") when walls moved. Hoist a unit cube and scale it instead. Two refs left dangling after the main-branch merge resolved its conflicts on GitHub: - floorplan-panel.tsx referenced a `theme` variable that no longer exists; the file already derives `isDark` from `getSceneTheme(state. sceneTheme).appearance === 'dark'` higher up. Use that. - grid.tsx applied `EDITOR_LAYER` but only imports `GRID_LAYER` (the new dedicated grid layer). Use the imported one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: pin building bbox center to cursor during move The floating drag button anchors at the building's bbox center, but the move tool was teleporting the building's origin to the cursor — so the moment a drag started the building jumped by `bbox_center - origin`. Capture the local-space offset from origin to bbox center at mount and apply it on every grid move, grid click, and R/T rotation, so the bbox center stays pinned to the cursor through the whole drag. Also seed the cursor sphere at the bbox center instead of the origin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * stair: 2D resize affordances + dispose curved/spiral geometry on swap Floorplan view: emit `move-arrow` children alongside the existing chrome, mirroring the in-world arrows on selected stairs: - straight: per-segment side (left/right width) and front (length) - curved & spiral: width, inner-radius, and two sweep-end arrows Hidden during placement so they don't fight the cursor follow. Stroke widths on curved/spiral chrome converted to screen pixels (paired with `non-scaling-stroke`); the old world-metre values rendered as sub-pixel at every zoom. First step line is now also emphasised on curved stairs to match legacy chrome. Skip the straight-only direction-arrow polyline for curved/spiral — the arc-aligned arrow above already conveys "up" and `buildFloorplanStairArrow` produces a malformed polyline once the chain is wrapped around an arc. Renderer: extract `SpiralColumnMesh` and `SpiralStepSupportMesh` and add the same prop-+-dispose pattern used by `CurvedStepMesh` / guide/renderer.tsx. Without disposing the prior BufferGeometry on each resize tick, WebGPU keeps a stale pipeline reference and flags "Vertex buffer slot 0 ... was not set" mid-drag on Lambert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: revert in-world ground menus to HTML floating; align grid to building - Wall/door/window/stair/stair-segment selection menus return to the shared HTML floating menu; remove the in-world ground icons, SVG textures, hysteresis/lerp constants, and unused imports across wall/door/window/stair-segment handle files. - Drop Move from the floating menu (the in-world side arrows cover it); delete the now-unused handleMove. - Floating menu scales with camera zoom (ortho.zoom or 1/distance), clamped at MIN 0.5 / MAX 1 so zoom-in keeps the default pixel size and zoom-out shrinks to a readable floor. - Per-type y-offsets tuned: wall 0.5, opening 0.6, landing 0.5, flight 0.75, parent stair 0.2, structural 0.4, default 0.05. - Align wall/fence arrow materials with the door/window pattern (depthTest/depthWrite false, transparent: true) so they render on top of geometry consistently. - Grid cellSize now follows `gridSnapStep` via a small `SnapAwareGrid` wrapper, and the grid mesh anchors its world XZ to the active building's mesh — snapped wall endpoints (in building-local coords) now fall on visible grid lines instead of mid-cell. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * handles: registry-driven in-world resize arrows Adds a `handles?: HandleDescriptor[] | (node) => HandleDescriptor[]` field to `NodeDefinition` so each kind declares its in-world resize affordances as pure data instead of shipping a bespoke React component. - New `packages/core/src/registry/handles.ts` exposes a discriminated union: `linear-resize` (axis + center/min/max anchor), `radial-resize` (1:1 outward growth), plus stubs for `arc-resize` and `endpoint-move` for follow-up migrations. - New `packages/editor/src/components/editor/node-arrow-handles.tsx` reads `def.handles`, mounts arrows with shared drag plumbing (raycast plane, NDC, pointer listeners, SFX, history pause, handle-dragging guard). Portal modes: `'parent'` (column-like, single wrapper rides self pose) and `'grandparent'` (door/window-like, outer wrapper rides parent pose + inner group rides self pose so handles escape the parent's selection-outline traversal). `apply` receives the node-at-drag-start so edge-anchored resizes (door width re-centers position) compute their fixed anchor from pre-drag state. - Migrate column, door, window, stair-segment. Old per-kind handle files (`column-side-handles.tsx`, `door-side-handles.tsx`, `window-side-handles.tsx`) removed; `stair-segment-handles.tsx` retains `StairHandles` (parent stair curved/spiral arrows) pending the `arc-resize` migration. - Column: height + crossSection-aware footprint (radius / uniform width=depth / independent width+depth / brace width+depth for non-vertical supports). - Door / window: edge-anchored width (left + right) with wall-length max bound; bottom-anchored height (door) / top + bottom edges (window). - Stair-segment: width (chain auto-centers), length anchored at chain start, height for step flights only (landings skip it). Wall and parent-stair curved/spiral arrows stay on legacy components for now — they need `endpoint-move` + `arc-resize` descriptor variants and rotated-axis projection, which are their own focused sessions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * handles: migrate wall + parent stair to registry; add arc-resize Closes the wall + parent-stair gap on the registry-driven handle migration. Net −1177 lines (the per-kind handle files were 1500+ lines of duplicated drag plumbing; their replacements are ~50-line config blocks on each NodeDefinition). - `arc-resize` reworked to take a raw `delta` (radians) instead of `newValue` so two-field writes like curved-stair sweep (which updates `sweepAngle` AND `rotation` together to keep the non-dragged edge world-fixed) stay in the descriptor without awkward inverse-currentValue gymnastics. `currentValue` removed from arc-resize for the same reason — applies own their math. - New `ArcArrow` renderer in `node-arrow-handles.tsx`: raycasts a horizontal drag plane at the arrow's Y, measures the signed angle delta around the node's local origin (atan2 in world XZ, normalised to [-π, π] so wraparound doesn't flip mid-gesture), hands the delta to `descriptor.apply` along with the initial node. - Wall: height arrow migrated (linear-resize axis='y' anchor='min', placement uses curve apex for curved walls, chord midpoint for straight). Side-move arrows + corner pickers stay on the legacy `wall-move-side-handles.tsx` because they're tap-to-engage-mode affordances (move whole wall / move endpoint), not drag-resize — modelling them in the registry needs an editor-action descriptor variant which is a follow-up. - Parent stair: curved + spiral stairs declare 5 handles — rise (linear-resize axis='y' anchor='min'), width (linear-resize axis='x' anchor='min'), inner-radius (linear-resize that also writes width to keep outer rim fixed), and sweep start / end (arc-resize variants writing sweepAngle + rotation). Straight stairs declare nothing — their segment children own resize. - Old `stair-segment-handles.tsx` (1405 lines) deleted; all its arrows now flow through the registry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * handles: tap-action descriptor + EditorApi; finish wall + fence migration Closes the final gap in the registry-driven handle migration. Wall side- move + corner pickers + fence side-move were the last legacy handles because they're click-to-engage-mode affordances (hand the node to its move tool / start an endpoint drag), not drag-resize — `apply(node, value, sceneApi)` had no path to editor state. - New `EditorApi` interface in core (alongside `SceneApi`) exposes `engageMove(node)` + `engageEndpointMove(node, endpoint)`. Concrete implementation in `packages/editor/src/lib/editor-api.ts` casts through `useEditor`'s setters so the descriptor layer never imports editor internals. - New `TapActionHandle` descriptor variant: `placement` + `onActivate (node, sceneApi, editorApi)`. `shape` field picks the visual — defaults to the chevron arrow; `'corner-picker'` renders the dashed vertical leader + billboarded hex disc + ring (sized to `nodeHeight(node)`). - `TapActionArrow` renderer in `node-arrow-handles.tsx` wires up pointer-down → descriptor.onActivate. Pulled the chevron and corner visuals into `ArrowShape` / `CornerPickerShape` building blocks so future shapes can be added without touching the descriptor union. - Wall: front/back side-move (engageMove) + start/end corner pickers (engageEndpointMove). Joined by the existing height arrow on the same `def.handles` list. Old `wall-move-side-handles.tsx` (600 lines) deleted — wall now has zero per-kind handle component. - Fence: front/back side-move. The bespoke endpoint move buttons in the floating menu stay until they migrate to a tap-action too. Net for this commit: -620 +513. Combined with the prior two migration commits: -2287 +912 across the full registry migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wall: revert side-move arrows + corner pickers to legacy component The registry-driven tap-action path didn't render the four non-height wall handles, even with descriptors resolved and the wall mesh in sceneRegistry. Fence uses the same descriptor shape and renders fine, so the bug is wall-specific and not in the descriptor layer itself — left for a real diagnosis later. Restored the pre-5756f241 wall-move-side-handles.tsx (height arrow + front/back side-move + start/end corner leaders, 753 lines) and mounted it next to NodeArrowHandles in editor/index.tsx. Dropped the def.handles field on wallDefinition so the two paths don't race. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: level naming helper + ambient floorplan render during building moves Two unrelated WIP fixes bundled: - Level names: extract \`getDefaultLevelName(n)\` / \`getLevelDisplayName(level)\` into \`packages/editor/src/lib/level-name.ts\` and swap in across rename inputs, command palette, floating selector, site panel, level-tree node, level-duplicate dialog, view toggles, and viewer-overlay breadcrumb. Default labels now read "Ground Floor" / "Floor N" / "Basement N" instead of the bare "Level N" string each caller was concatenating itself. - Building-move ambient floorplan: when a building is selected (or mid-move) without an explicit level, FloorplanRegistryLayer falls back to that building's level 0 (or lowest level) and renders it dimmed + non-interactive so the floor stays visible as context instead of disappearing. FloorplanPanel allows the SVG to mount in that case. MoveBuildingTool publishes per-frame pose to useLiveTransforms so the floor-plan follows the drag without reading from the Three.js mesh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * handles: 3D resize arrows publish to useLiveNodeOverrides, commit on release Door / window / wall height-arrow drags now stage the patch in \`useLiveNodeOverrides\` each frame and write to zustand exactly once on pointerup. The kind's system reads via \`getEffectiveNode\` and rebuilds the mesh imperatively, so the React tree never re-renders mid-drag and undo isn't polluted by per-frame writes. - \`packages/core\`: shared \`getEffectiveNode<T>(node)\` helper exported from \`@pascal-app/core\`; spreads any override fields onto the input, returns it unchanged when none. Replaces the inline merge wall-system had as \`getEffectiveWall\`. - \`DoorSystem\` / \`WindowSystem\`: subscribe to \`useLiveNodeOverrides.overrides\` (so override-only ticks re-run the component and pick up the latest dirtyNodes), merge via \`getEffectiveNode\` before \`updateXMesh\`. Parent-wall dirty cascade uses the effective node's parentId. - \`WallSystem.updateWallGeometry\`: door / window children are merged through \`getEffectiveNode\` before being passed to \`generateExtrudedWall\`, so cutouts track the in-flight resize. - \`LinearArrow\` (registry handle): onMove → override + markDirty; onUp → one tracked \`sceneApi.update(lastPatch)\` + clear; onCancel → clear + markDirty to revert geometry. - Legacy \`WallHeightArrowHandle\` in wall-move-side-handles.tsx switched to the same pattern (was the only inline-drag handle in that file — side-move + corner pickers hand off to other tools). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * floating menu: enable Move icon for wall / door / window Widens the \`onMove\` gate on \`NodeActionMenu\` so wall, door, and window join column in showing the Move chevron. \`handleMove\` calls \`setMovingNode(node)\` which dispatches through the existing \`affordanceTools.move\` path on each kind's definition (already present for all three). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * handles: live drag, guide rings, dimension chips; floating-menu Move on stairs - Window bottom height arrow: flip Z rotation so chevron points down when placement Y < 0. Door / column height arrows unaffected (still above the node). - Floating menu: raise stair-segment offsets (segmentType is 'stair' | 'landing', so the legacy 'stair-flight' key was dead); enable the Move icon for parent stair and stair-segment. - HandleDecoration on LinearResizeHandle + RadialResizeHandle. Generic GuideRing renders at node-local (0, y, 0) in the XZ plane when the arrow is hovered or dragging. Curved/spiral stair width arrow gets an outer rim ring, inner-radius arrow gets an inner pillar ring, and column radius arrow gets a footprint ring on round / octagonal / sixteen-sided shafts. - ArcArrow migrated to the live-override pattern (sweepAngle + rotation). NodeArrowHandles subscribes to useLiveNodeOverrides for the selected node and merges into the effective node, so arrow positions, decorations, and dimension chips all track the in-flight drag instead of freezing at pre-drag values. - StairRenderer and ColumnRenderer subscribe narrowly to their own override entry and render against the merged effective node, so the curved/spiral mesh and the column body update per pointer move without zustand churn. - DimensionLabel chip (<Html>) rendered next to every linear-resize / radial-resize arrow on hover or drag. Format follows the wall / fence label recipe (metric / imperial via useViewer.unit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fence + column: registry handles, brace spread arrows, per-style defaults - Fence: side-move arrows already on the registry path; add the height arrow (axis 'y' linear-resize, anchor min) + start/end corner pickers (tap-action, shape 'corner-picker' with dashed leader + billboarded hex). Move icon enabled on the floating menu; the menu floats above the height arrow via a fence-specific MENU_Y_OFFSET. Endpoint move buttons + Alt-detach plumbing removed from the floating menu — corner pickers cover that flow. Legacy wall-move-side-handles.tsx no longer branches into fence (dedupes the side-move arrows that were stacked). - Column: bottom + top spread arrows for non-vertical supports — anchor 'center' so dragging the right leg outward grows the full leg-to-leg span symmetrically. Conditionally added per supportStyle: - a-frame: both bottom and top spreads - y-frame / v-frame: top spread only Per-style preset map applied on supportStyle switch (panel.tsx) so every style snaps to its renderer's natural proportions (defaults lifted from each support's fall-through expressions); a customised A-frame switched to Y-frame no longer carries its 1.4 m bottom into state, and an X-brace gets equal parallel legs rather than inheriting A-frame's pinched 0.12 m top. - GeometrySystem: merge `getEffectiveNode(node)` before calling `def.geometry`. Smooths drags for every kind on the parametric path (fence, shelf, item, anything that ships `def.geometry`): live override mutates the mesh per pointer move, zustand only hears the commit. Mirrors WallSystem / DoorSystem / WindowSystem / StairRenderer / ColumnRenderer hookups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * elevator + column: registry handles, rotation gizmo with curved arrow - Elevator: width / depth / cab-height arrows on the registry path (anchor='center' for width/depth so dragging outward grows the full span symmetrically; anchor='min' for cab-height with shaftTopY resolved through `resolveElevatorLevels` so the arrow lands above the full shaft on multi-level elevators, not just the cab top). Floating-menu Move icon enabled + a fence-style MENU_Y_OFFSET so the menu floats above the height arrow. - Whole-node rotation gizmo for both elevator and column. Uses arc-resize with `shape: 'rotate'` + a new `decoration` ring on ArcResizeHandle. Curved-arrow geometry is a two-headed icon (arc ribbon with chevron wings + tangential tip at each end), rendered in node-local XZ plane at mid-height. Guide ring traces the rotation circle (footprint-diagonal + small offset) on hover or drag. Position offsets along +Z only — sticks out the front of the node instead of diagonally at the corner. apply() negates the cursor angular delta (atan2(z,x) is opposite-handed from three.js Ry) so dragging CCW around the node rotates the node CCW. - ArcArrow renderer extended: tracks `isDragging` like LinearArrow, renders the optional ring decoration, and swaps geometry between the chevron (default, used by stair-sweep handles) and the new curved-arrow shape when `shape: 'rotate'` is set. - HandlePlacement.position / .rotationY now optionally take a `sceneApi` so descriptors that depend on cross-node state (the elevator's level-chain resolution) can compute placement against the live scene. SceneApi gains a `nodes()` accessor returning the full record. Test stubs in core (relations-resolver, drag-session, hosting) updated to satisfy the new shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * slab + ceiling + shelf: registry handles, live polygon preview, cursor polish - slab: per-edge resize chevrons in PolygonEditor (gated on `allowEdgeMove`, so site / zone editors are unaffected), height arrow via `def.handles`, and a floating-menu Move icon. Polygon drags now publish the in-flight polygon to `useLiveNodeOverrides` through a new `onPolygonPreview` prop; GeometrySystem rebuilds the slab mesh at pointer rate while the store stays untouched until the single commit on release. Hole editor wired the same way. Handle materials switched meshStandard → meshBasic so the blue corner / green midpoint cylinders read true colour instead of dimming in scene lighting. - ceiling: same Move icon, per-edge arrows, height arrow, and live preview through the boundary + hole editors. CeilingSystem now merges via `getEffectiveNode`, so polygon and height overrides flow through on every dirty tick. Height arrow placement is mesh-local (not `height + offset`) because CeilingSystem parks `mesh.position.y` on the height value. - shelf: width / depth / height arrows + a curved rotation gizmo with ring decoration. Move icon on the floating menu. Shelf stores rotation as a tuple, so the rotate `apply` reads back `[x, y, z]` and only mutates `y`. - LinearArrow: snapshot `rideObject.matrixWorld.invert()` at drag-start and reuse it in `onMove`. Kinds that park `mesh.position` on the field being dragged (ceiling `height`) used to chase a moving ride frame, so the local-Y delta collapsed and the value stalled / jittered. - ArcArrow: cursor is `'grab'` on hover and `'grabbing'` during the drag (was the misleading `'ew-resize'`); the `Cursor` type gains those two members. - ParametricNodeRenderer: merge `useLiveNodeOverrides` for position + rotation so the rotation gizmo shows live motion through the outer group — GeometrySystem already covered geometry-affecting fields. - floating menu offsets: slab 0.4 → 0.7, ceiling 0.4 → 1.0, shelf 0.6, so the menu floats above each kind's new height arrow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * roof-segment: registry handles, live override path, analytical-normal fix - roof-segment: width / depth / wall-height / pitch / rotation arrows; pitch drag back-solves the angle from peak-height via the slope frame - roof-system: getEffectiveNode + useLiveNodeOverrides so drags rebuild the segment + merged shell live, commit-on-release stays a single write - floating menu: Move icon for roof-segment; uniform EXTRA_MENU_LIFT - skylight / solar-panel / box-vent ghost: fix analytical normal — shed sign flip, mansard / dutch +X face direction, gambrel + mansard tier awareness; one (dx·tan, 1, dz·tan) formula across all roof types Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * floorplan: resize/rotate arrows for column, shelf, elevator, fence; rotation handedness fix - Column / shelf / elevator: per-cross-section resize arrows in the 2D floor plan, matching the 3D handle set (width / depth / uniform / radius / brace dims), plus a corner rotate-arrow. Body move stays on the move-handle dot via the registry overlay's generic translate. - Fence: floor-plan curve sagitta handle + side move-arrows + a body-move target (`fenceFloorplanMoveTarget`) with linked-fence endpoint cascade and ALT-detach. Commit strips `isNew` metadata and re-selects so the chrome stays visible at the new position. - Roof-segment: floor-plan resize + rotate arrows wired through new affordances; `resolveSegmentFrame` aligns with the builder's transform so handles stay glued to the rendered footprint. - Stair: in-world rotate gizmo bow orientation derived from the gizmo's position (was a stray `-π/4` that read as "pointing outward" on the spiral). StairSystem now merges the live override before the slab-elevation spatial query, so dragging the rotate gizmo no longer drops the group's Y when a segment swings off its pre-drag footprint. - Rotation handedness: floor-plan now plots column / shelf / roof-segment at `-rotation` so SVG's CW-with-y-down `rotate` visually matches Three.js Y-rotation (CCW from top-down). Same `rotation` value rotates the same direction in both views, and the same cursor gesture writes the same sign — `- delta` in every rotate affordance, lined up with the 3D handles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * registry: floorplanScope + movable capability; cursor focus under mouse - Registry: duplicate-kind throws in production, warns in dev (HMR). New `kindsWithFloorplanScope('building')` and `isRegistryMovable` helpers; `resolveBuildingForLevel` extracted into spatial-grid-sync. - Floorplan registry layer: building-scoped kinds (elevator today) now dispatched via `def.floorplanScope === 'building'` instead of a hardcoded `node.type === 'elevator'` arm. - FloatingActionMenu: Move button gated by `isRegistryMovable(kind)`, replacing the 13-arm `node?.type === '…'` chain so adding a movable kind no longer touches this file. - 2D cursor indicator: render at the raw mouse position in all modes (drop the snapped `cursorAnchorPosition` machinery) so the badge always sits under the cursor. - 3D grid reveal ring: the shader's `positionLocal.xy` is in grid-mesh-local space, but the cursor uniform was in world coords — so the ring drifted by the building's world XZ. Store the last world cursor and re-derive the local uniform every frame after the mesh's XZ lerp, so the ring stays locked under the mouse including during the catch-up frames after a building rotation commits. - Roof system: tighten the merged-shell filter's type predicate so TS narrows `n` before `hasSegmentMaterialOverride(n)`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wall: corner billboard, perpendicular grid snap, drop 45° from move/draft - Corner picker discs (3D move + wall corner leader) now solve `parentWorld⁻¹ · cameraWorld` so they face the camera even when an ancestor building/level has a rotation; the old `camera.quaternion` copy silently broke under any parent rotation. - Side-handle wall move snaps the wall centre's *absolute* perpendicular projection to grid lines, so axis-aligned walls land on real grid positions regardless of where they started. - Wall draft + endpoint move (3D and 2D) drop the 45°-from-start angle snap. It was useful for picking a direction during the very first draft, but during a perpendicular endpoint drag it pulls the cursor onto a 45° ray from the fixed corner instead of tracking the grid. - Shift now selects the fine grid step (`WALL_FINE_GRID_STEP = 0.05`) for precision placement in every wall snap call site, replacing the former "Shift = bypass angle snap" semantics with a consistent "Shift = finer snap" convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fence: drop 45° angle snap from draft + endpoint move, Shift = fine step Mirrors the wall convention shipped in e89a8228: - `snapFenceDraftPoint` gains an optional `step` override. - Fence draft (3D `tool.tsx` and 2D `floorplan-panel.tsx`, `use-floorplan-background-placement.ts`) snaps to the active grid step only — no 45°-from-start snap. Shift switches to `WALL_FINE_GRID_STEP` for precision placement. - Fence endpoint move (3D `actions/move-endpoint.ts` and 2D `floorplan-affordances.ts`) drops `start`/`angleSnap` so a perpendicular drag tracks the grid instead of pulling onto a 45° ray from the fixed endpoint. Shift switches to the fine step. Also fixes the matching wall click path in `use-floorplan-background-placement.ts:215` that was missed in e89a8228, plus its locally-injected `snapWallDraftPoint` signature. Side-handle perpendicular slide (`fence/move-tool.tsx`) was already grid-snap-only without 45°, so it's untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * floorplan menu: show Move button for selected walls Removes the `node.type !== 'wall'` exclusion that hid the Move icon on the 2D floor-plan floating menu for walls. The dispatcher already has a working path for walls — `def.affordanceTools.move` routes to `MoveWallTool` (perpendicular slide + linked-wall cascade) — so the menu just needs to expose the button. The original opt-out called the menu entry "redundant" because walls also have side-arrow handles, but the user wants the same icon walls get the same affordance as every other selected element in the floating menu. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * floorplan wall move: axis-lock to wall normal, match 3D MoveWallTool The 2D `wallFloorplanMoveTarget` was applying the raw cursor delta in XZ, so dragging a selected wall in the floor plan let it free-float sideways and lengthwise. The 3D `MoveWallTool` constrains the same drag to the wall's perpendicular axis (sideways slide only) — this brings the 2D path into parity. - Captures the wall's centre and the `getPerpendicularWallMoveAxis` normal at session start. - Each tick, projects `originalCentre + rawDelta` onto the axis, snaps that absolute scalar to the active grid step, and translates the wall by `axis * perpDelta`. Same math as the 3D tool. - Shift bypasses snap (raw projection), matching the 3D convention. - Degenerate zero-length walls fall back to free XZ motion (rare; they're already destined for deletion via the junction planner). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * floorplan move overlay: commit at last pointermove, not pointer-up The overlay used to re-run \`session.apply\` with the pointer-up coordinates before committing, on the assumption that pointer-up might fire without a preceding pointermove. Side effect: when the pointer-up coord crossed a grid-snap boundary relative to the last pointermove, the snap flipped to a different cell and the moved node visibly jumped at release from where the drag had painted it. Trust the last pointermove instead — modern browsers reliably emit a final pointermove right before pointerup, and "what you saw is what gets committed" is the UX users expect. The previous sub-pixel drift fix loses to the visible boundary-jump it caused. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * floorplan: door wall-hit placement, fence/stair move fixes, wall auto-ceiling sync - Door / window placement: registry layer entries no longer swallow pointer events while a door / window tool is active, so clicking ON a wall now triggers placement (previously only clicks NEAR a wall worked — the wall's registry-entry `<g>` was stopping the pointer event before it reached the SVG background handler that emits `wall:click`). - Fence floor-plan move: 3D `MoveFenceTool` now respects `movingNodeOrigin === '2d'` on unmount. Without the guard, the 2D overlay's commit would call `setMovingNode(null)`, unmounting the 3D tool, whose cleanup then ran `restoreOriginal()` and reverted the just-committed positions — the "fence reverts on commit" symptom. Mirrors the wall move-tool's existing guard. - Stair floor-plan move: anchored, delta-based motion (was position-jumps-to-snapped-cursor), and reads `getWallGridStep()` instead of hard-coded 0.5 so the stair snaps to the editor's current grid step in real time. Matches the 3D `MoveRegistryNode` commit position. - Stair segment length arrow: drop the placement `rotationY` — `axis: 'z'` already auto-rotates the chevron by `-π/2`, stacking another `-π/2` spun the tip to `-X` (sideways) instead of `+Z` (forward off the run). Matches shelf / roof-segment. - Stair segment system: merge `useLiveNodeOverrides` when rebuilding geometry, chain transforms, merged mesh, and slab elevation, so width / length / height drags show the live value on the mesh and the store only gets the final tracked write on commit. - Stair length arrow position: offset 0.06 m past the front edge so the head clears the stair fill and reads as pointing forward off the run rather than lying across the edge. - Stair default railing mode: `'both'` for new placements (was `'right'`). - Wall floor-plan move: anchored at first cursor sample (was raw centre) so the floating-menu drag-icon offset doesn't jump the wall to a different snap cell on grab. - Wall move: live auto-slab + auto-ceiling preview via `useLiveNodeOverrides`. The store stays at pre-drag values during the drag; commit writes the final plan in one atomic `applyNodeChanges` (creates / updates / deletes deferred from per-tick to commit so undo rolls the whole topology change back in one step). Adds `planAutoCeilingsForLevel` + `AutoCeilingSyncPlan` exports mirroring the existing auto-slab planner. - Wall draft: expose `WALL_ENDPOINT_SNAP_RADIUS` (0.7 m) for endpoint snap intent — strongest user intent (closing polygons, attaching to corners) wants a more generous radius than the generic join snap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * open-pr skill: refresh existing PR description instead of bailing Adds a 3b branch to the open-pr skill: when gh pr view finds an existing PR, regenerate the body from current branch commits/diff while preserving Screenshots verbatim and the user's checklist tick state, then apply via gh pr edit. Previously the skill would print the URL and exit, leaving stale descriptions on long-lived feature branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * floorplan: capability/hook dispatch, generic resize + endpoint state Removes per-kind `node.type ===` arms from the floorplan layer (door/window opening placement, wall live-override merge, building ambient context) in favour of new NodeDefinition capabilities and hooks. Collapses 12 `resizing*` editor-store fields into one `activeHandleDrag`, the wall/fence endpoint-move dispatch into a kind-keyed table, and renames now-shared wall utilities to segment-generic names. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 3d wall measurement: hide label for selected walls Item measurements still render; the wall branch is left in place so re-enabling is a one-line gate flip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: set EDITOR_LAYER on arrow handles to exclude from thumbnails Arrow meshes portaled into the 3D scene were missing EDITOR_LAYER, so ThumbnailGenerator's camera (which calls cam.layers.disable(EDITOR_LAYER)) would render selection handles into captures. Add a useEffect in NodeArrowHandlesForNode that traverses the portal root group and sets EDITOR_LAYER on every child. The effect re-runs whenever descriptors change so newly created meshes (e.g. when handle count changes for the same selected node) get the layer tag immediately. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: open-pascal <open@pascal.app>
This commit is contained in:
co-authored by
Claude Opus 4.6
open-pascal
parent
6b0fe5b99c
commit
c00a2469fd
@@ -28,6 +28,40 @@ export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): s
|
||||
return 'default' // fallback for orphaned items
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the building id that contains the given level, or `null` if
|
||||
* the level is unparented or no enclosing building exists.
|
||||
*
|
||||
* Most scenes record the relationship via `level.parentId →
|
||||
* building.id`, but older serialisations occasionally drop `parentId`
|
||||
* even though the building's `children` array still references the
|
||||
* level. The fallback scan covers that case.
|
||||
*
|
||||
* Used by `FloorplanRegistryLayer` to discover building-scoped kinds
|
||||
* (`def.floorplanScope === 'building'`) without hardcoding any kind
|
||||
* name in the editor layer.
|
||||
*/
|
||||
export function resolveBuildingForLevel(
|
||||
levelId: AnyNodeId,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): AnyNodeId | null {
|
||||
const level = nodes[levelId] as AnyNode | undefined
|
||||
if (!level) return null
|
||||
const directParent = (level as { parentId?: AnyNodeId | null }).parentId ?? null
|
||||
if (directParent) {
|
||||
const candidate = nodes[directParent]
|
||||
if (candidate?.type === 'building') return candidate.id as AnyNodeId
|
||||
}
|
||||
for (const candidate of Object.values(nodes)) {
|
||||
if (candidate?.type !== 'building') continue
|
||||
const children = (candidate as { children?: AnyNodeId[] }).children
|
||||
if (Array.isArray(children) && children.includes(levelId)) {
|
||||
return candidate.id as AnyNodeId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Call this once at app initialization. Returns an unsubscribe function that
|
||||
// detaches the scene-store listener (useful when the editor is unmounted so
|
||||
// the spatial grid singleton does not hold stale references to old scenes).
|
||||
|
||||
@@ -40,6 +40,7 @@ export {
|
||||
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
||||
export {
|
||||
initSpatialGridSync,
|
||||
resolveBuildingForLevel,
|
||||
resolveLevelId,
|
||||
} from './hooks/spatial-grid/spatial-grid-sync'
|
||||
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
||||
@@ -53,11 +54,13 @@ export {
|
||||
} from './lib/door-operation'
|
||||
export { getRenderableSlabPolygon } from './lib/slab-polygon'
|
||||
export {
|
||||
type AutoCeilingSyncPlan,
|
||||
type AutoSlabSyncPlan,
|
||||
detectSpacesForLevel,
|
||||
initSpaceDetectionSync,
|
||||
isSpaceDetectionPaused,
|
||||
pauseSpaceDetection,
|
||||
planAutoCeilingsForLevel,
|
||||
planAutoSlabsForLevel,
|
||||
resumeSpaceDetection,
|
||||
type Space,
|
||||
@@ -100,6 +103,7 @@ export {
|
||||
export {
|
||||
default as useLiveNodeOverrides,
|
||||
type LiveNodeOverrides,
|
||||
getEffectiveNode,
|
||||
} from './store/use-live-node-overrides'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
@@ -172,11 +176,14 @@ export {
|
||||
} from './systems/wall/wall-mitering'
|
||||
export {
|
||||
constrainWallMoveDeltaToAxis,
|
||||
getLinkedWallUpdates,
|
||||
getPerpendicularWallMoveAxis,
|
||||
getPlannedLinkedWallUpdates,
|
||||
planWallMoveJunctions,
|
||||
type WallMoveAxis,
|
||||
type WallMoveBridgePlan,
|
||||
type WallMoveJunctionPlan,
|
||||
type WallMoveLinkedWallTargetPlan,
|
||||
type WallPlanPoint,
|
||||
} from './systems/wall/wall-move'
|
||||
export type { SceneGraph } from './utils/clone-scene-graph'
|
||||
|
||||
@@ -47,6 +47,12 @@ export type AutoSlabSyncPlan = {
|
||||
delete: Array<SlabNodeType['id']>
|
||||
}
|
||||
|
||||
export type AutoCeilingSyncPlan = {
|
||||
create: CeilingNodeType[]
|
||||
update: Array<{ id: CeilingNodeType['id']; data: Partial<CeilingNodeType> }>
|
||||
delete: Array<CeilingNodeType['id']>
|
||||
}
|
||||
|
||||
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
|
||||
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
|
||||
const ROOM_CURVE_TOLERANCE = 0.04
|
||||
@@ -650,12 +656,10 @@ function syncAutoSlabsForLevel(
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoCeilingsForLevel(
|
||||
levelId: string,
|
||||
export function planAutoCeilingsForLevel(
|
||||
roomPolygons: Point2D[][],
|
||||
existingCeilings: CeilingNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
): AutoCeilingSyncPlan {
|
||||
const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls)
|
||||
const manualSignatures = new Set(
|
||||
manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))),
|
||||
@@ -782,16 +786,31 @@ function syncAutoCeilingsForLevel(
|
||||
)
|
||||
}
|
||||
|
||||
if (ceilingsToDelete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(ceilingsToDelete)
|
||||
return {
|
||||
create: ceilingsToCreate,
|
||||
update: ceilingsToUpdate,
|
||||
delete: ceilingsToDelete,
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoCeilingsForLevel(
|
||||
levelId: string,
|
||||
roomPolygons: Point2D[][],
|
||||
existingCeilings: CeilingNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings)
|
||||
|
||||
if (plan.delete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(plan.delete)
|
||||
}
|
||||
|
||||
if (ceilingsToUpdate.length > 0) {
|
||||
sceneStore.getState().updateNodes(ceilingsToUpdate)
|
||||
if (plan.update.length > 0) {
|
||||
sceneStore.getState().updateNodes(plan.update)
|
||||
}
|
||||
|
||||
if (ceilingsToCreate.length > 0) {
|
||||
sceneStore.getState().createNodes(ceilingsToCreate.map((node) => ({ node, parentId: levelId })))
|
||||
if (plan.create.length > 0) {
|
||||
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ function buildFixture() {
|
||||
function makeScene(nodes: Record<string, AnyNode>): SceneApi {
|
||||
return {
|
||||
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
||||
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
update: () => {},
|
||||
upsert: () => ID(''),
|
||||
delete: () => {},
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// In-world resize / move arrow descriptors. Each `NodeDefinition` may
|
||||
// declare a `handles` list (or a `(node) => list` function for shape-
|
||||
// dependent affordances). The editor mounts a single generic component
|
||||
// that reads these descriptors and renders the arrows / drag logic — no
|
||||
// per-kind handles file needed.
|
||||
//
|
||||
// Pure data + small per-descriptor callbacks: no Three.js, React, or
|
||||
// editor imports here so this stays in core. The descriptors are
|
||||
// evaluated by the editor at drag time (`apply` etc.) so the callbacks
|
||||
// run in the editor's context — they see the live node and the scene
|
||||
// API but otherwise do not import 3D libraries.
|
||||
//
|
||||
// Layered intentionally:
|
||||
// - axis-resize : symmetric scaling around center (column W/D, height)
|
||||
// - edge-resize : anchored on one edge, the other follows the pointer
|
||||
// (door width: drag right edge, left edge stays)
|
||||
// - vertical-resize: linear-resize specialised for world-Y (height arrow
|
||||
// anchored at bottom; window top-edge anchored at
|
||||
// bottom; window bottom-edge anchored at top)
|
||||
// - radial-resize : 1:1 outward growth of a radial field (column radius)
|
||||
// - arc-resize : curved/spiral stair sweep / inner-radius / rise
|
||||
// - endpoint-move : wall / fence endpoint drag (snapping is bespoke,
|
||||
// so it delegates to a kind-supplied callback)
|
||||
|
||||
import type { AnyNode } from '../schema/types'
|
||||
import type { SceneApi } from './types'
|
||||
|
||||
/**
|
||||
* Editor-facing verbs that handle descriptors can invoke.
|
||||
*
|
||||
* Parallel to {@link SceneApi} but exposes EDITOR state mutations (move
|
||||
* tools, endpoint dragging, etc.) instead of scene-data writes. Descriptors
|
||||
* receive a concrete implementation from the editor at drag time — `core`
|
||||
* only carries the interface so node definitions can call into editor
|
||||
* affordances without importing the editor package.
|
||||
*
|
||||
* Minimal verb set today; grow it as new descriptor variants land
|
||||
* (engageCurve for wall/fence curving, etc.).
|
||||
*/
|
||||
export type EditorApi = {
|
||||
/**
|
||||
* Hand the node to its registered move tool (the same path the floating
|
||||
* menu's Move icon uses). Implementations clear any in-progress endpoint
|
||||
* or curving state so the move starts from a clean slate.
|
||||
*/
|
||||
engageMove: (node: AnyNode) => void
|
||||
/**
|
||||
* Engage endpoint drag for kinds that own start / end anchors (walls,
|
||||
* fences). No-ops for kinds without endpoints.
|
||||
*/
|
||||
engageEndpointMove: (node: AnyNode, endpoint: 'start' | 'end') => void
|
||||
}
|
||||
|
||||
export type HandlePortal = 'self' | 'parent' | 'grandparent'
|
||||
|
||||
export type HandleAxis = 'x' | 'y' | 'z'
|
||||
|
||||
export type HandleAnchor = 'center' | 'min' | 'max'
|
||||
|
||||
/** 3D position + rotation of the arrow in its portal target's local space. */
|
||||
export type HandlePlacement<N> = {
|
||||
/**
|
||||
* `sceneApi` is supplied so descriptors that depend on cross-node state
|
||||
* (elevator height resolving level entries, future cross-kind handles)
|
||||
* can compute placement against the live scene. Existing descriptors
|
||||
* that only need `node` can ignore the second argument.
|
||||
*/
|
||||
position: (node: N, sceneApi: SceneApi) => readonly [number, number, number]
|
||||
/** Optional Y rotation (radians). Defaults to 0. */
|
||||
rotationY?: (node: N, sceneApi: SceneApi) => number
|
||||
}
|
||||
|
||||
export type Cursor = 'ew-resize' | 'ns-resize' | 'move' | 'grab' | 'grabbing'
|
||||
|
||||
/**
|
||||
* Visual decoration shown alongside a handle while the user is hovering
|
||||
* or dragging it. Today: a thin horizontal ring at a node-local radius —
|
||||
* the curved-stair width / inner-radius arrows use this to trace the
|
||||
* outer rim / inner pillar so the user sees what the drag affects.
|
||||
*
|
||||
* Pure data: the editor's arrow renderer reads it and mounts the visual.
|
||||
*/
|
||||
export type HandleDecoration<N> = {
|
||||
kind: 'ring'
|
||||
/** Node-local radius of the ring (XZ plane). */
|
||||
radius: (node: N) => number
|
||||
/** Node-local Y of the ring. Defaults to 0. */
|
||||
y?: (node: N) => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear resize along a single local axis. Covers width / depth / height
|
||||
* arrows whose visible behaviour is "drag the +axis edge, the dimension
|
||||
* grows."
|
||||
*
|
||||
* `anchor` controls which side stays fixed:
|
||||
* - 'center' : symmetric — both edges move ±delta (column width/depth).
|
||||
* - 'min' : the -axis edge is fixed; drag the +axis edge by `delta`
|
||||
* grows the value by `delta` (column height with origin at
|
||||
* base; door height with bottom anchored).
|
||||
* - 'max' : the +axis edge is fixed; drag the -axis edge.
|
||||
*
|
||||
* `apply(node, newValue)` returns the partial patch. Use it to write
|
||||
* sibling fields too (e.g. door 'max' anchor re-centers `position[0]`).
|
||||
*/
|
||||
export type LinearResizeHandle<N> = {
|
||||
kind: 'linear-resize'
|
||||
/** Local axis. The arrow's chevron points along +axis. */
|
||||
axis: HandleAxis
|
||||
anchor: HandleAnchor
|
||||
currentValue: (node: N) => number
|
||||
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
|
||||
min?: number | ((node: N, sceneApi: SceneApi) => number)
|
||||
max?: number | ((node: N, sceneApi: SceneApi) => number)
|
||||
placement: HandlePlacement<N>
|
||||
/**
|
||||
* Defaults to 'self' (arrow lives in the selected node's own mesh).
|
||||
* 'parent' uses the parent mesh — used by doors/windows whose handles
|
||||
* need to ride the wall's rotation.
|
||||
*/
|
||||
portal?: HandlePortal
|
||||
cursor?: Cursor
|
||||
/** Optional visual guide shown while the arrow is hovered or dragging. */
|
||||
decoration?: HandleDecoration<N>
|
||||
}
|
||||
|
||||
/**
|
||||
* 1:1 outward growth — dragging the arrow outward by `delta` grows the
|
||||
* value by `delta` (the visible edge follows the pointer). Use for radii
|
||||
* and other fields where the conceptual model is "the +axis edge IS the
|
||||
* thing being moved" rather than "the size IS being scaled."
|
||||
*/
|
||||
export type RadialResizeHandle<N> = {
|
||||
kind: 'radial-resize'
|
||||
axis: HandleAxis
|
||||
currentValue: (node: N) => number
|
||||
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
|
||||
min?: number | ((node: N, sceneApi: SceneApi) => number)
|
||||
max?: number | ((node: N, sceneApi: SceneApi) => number)
|
||||
placement: HandlePlacement<N>
|
||||
portal?: HandlePortal
|
||||
/** Optional visual guide shown while the arrow is hovered or dragging. */
|
||||
decoration?: HandleDecoration<N>
|
||||
}
|
||||
|
||||
/**
|
||||
* Curved / spiral stair sweep arrows. The renderer raycasts a horizontal
|
||||
* plane through the arrow's Y and emits the angular delta (radians,
|
||||
* signed, normalised to [-π, π]) around the node's local origin.
|
||||
*
|
||||
* Unlike the linear variants, `apply` receives the raw cursor delta
|
||||
* (not a `newValue`) because sweep handles typically write multiple
|
||||
* fields off the delta (`sweepAngle` AND `rotation` — re-orienting the
|
||||
* arc so the opposite edge stays world-fixed). Descriptor-internal
|
||||
* math handles the per-end sign and any clamping; the renderer stays
|
||||
* out of it.
|
||||
*/
|
||||
export type ArcResizeHandle<N = any> = {
|
||||
kind: 'arc-resize'
|
||||
/**
|
||||
* Marks the drag mode. Only 'angular' uses the polar plane renderer;
|
||||
* 'radial' and 'vertical' degenerate to `linear-resize` (axis 'x' /
|
||||
* 'y') so descriptors should prefer that for those cases.
|
||||
*/
|
||||
axis: 'angular'
|
||||
/** Optional metadata for descriptors that bundle two handles per kind. */
|
||||
end?: 'start' | 'end'
|
||||
apply: (initialNode: N, delta: number, sceneApi: SceneApi) => Partial<N>
|
||||
placement: HandlePlacement<N>
|
||||
portal?: HandlePortal
|
||||
/** Optional visual guide shown while the arrow is hovered or dragging. */
|
||||
decoration?: HandleDecoration<N>
|
||||
/**
|
||||
* Visual override. Defaults to the standard chevron (used by the
|
||||
* stair-sweep extend handles). 'rotate' renders a two-headed curved
|
||||
* arrow icon, intended for whole-node rotation handles.
|
||||
*/
|
||||
shape?: 'chevron' | 'rotate'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall / fence endpoint drag. Snapping and adjacency belong to the kind,
|
||||
* so the descriptor declares the placement and hands the world-space
|
||||
* pointer position back to `apply`. The kind can splice walls, snap to
|
||||
* a grid, merge with a neighbour, etc., and returns the partial patch.
|
||||
*/
|
||||
export type EndpointMoveHandle<N> = {
|
||||
kind: 'endpoint-move'
|
||||
endpoint: 'start' | 'end'
|
||||
placement: HandlePlacement<N>
|
||||
/** Called with the world-space hit on the ground plane. */
|
||||
apply: (
|
||||
node: N,
|
||||
worldPoint: readonly [number, number, number],
|
||||
sceneApi: SceneApi,
|
||||
) => Partial<N>
|
||||
portal?: HandlePortal
|
||||
}
|
||||
|
||||
// Default to `any` so type-erased renderers can hold `HandleDescriptor[]`
|
||||
// without each variant's contravariant `currentValue: (node: N) => ...`
|
||||
// callback fighting the union widening. Per-kind defs supply a real N.
|
||||
/**
|
||||
* Click-to-engage affordance. The descriptor doesn't drive a drag — its
|
||||
* single job is to mount a click target at `placement` and dispatch a
|
||||
* verb on the editor API when the user clicks. Used by wall side-move
|
||||
* (engage move tool) and wall corner pickers (engage endpoint move).
|
||||
*
|
||||
* The renderer picks the visual from `shape`. Default `'arrow'` reuses
|
||||
* the chevron shape every resize handle uses. `'corner-picker'` renders
|
||||
* a dashed vertical leader + billboarded hex disc + ring, anchored at
|
||||
* `placement.position` and extending up to `nodeHeight(node)`.
|
||||
*/
|
||||
export type TapActionHandle<N = any> = {
|
||||
kind: 'tap-action'
|
||||
placement: HandlePlacement<N>
|
||||
/**
|
||||
* Dispatched on pointer-down. Use scene/editor APIs to read state +
|
||||
* trigger the desired action.
|
||||
*/
|
||||
onActivate: (node: N, scene: SceneApi, editor: EditorApi) => void
|
||||
/** Visual override; defaults to the standard chevron arrow. */
|
||||
shape?: 'arrow' | 'corner-picker'
|
||||
/**
|
||||
* Required when `shape: 'corner-picker'` — controls the dashed leader's
|
||||
* vertical extent. Pure callback so the descriptor doesn't need to
|
||||
* import 3D libs.
|
||||
*/
|
||||
nodeHeight?: (node: N) => number
|
||||
portal?: HandlePortal
|
||||
cursor?: Cursor
|
||||
}
|
||||
|
||||
export type HandleDescriptor<N = any> =
|
||||
| LinearResizeHandle<N>
|
||||
| RadialResizeHandle<N>
|
||||
| ArcResizeHandle<N>
|
||||
| EndpointMoveHandle<N>
|
||||
| TapActionHandle<N>
|
||||
|
||||
/**
|
||||
* Static array, or a function for shape-dependent cases (column
|
||||
* crossSection / supportStyle, stair-segment segmentType, etc.).
|
||||
*/
|
||||
export type HandleList<N> = HandleDescriptor<N>[] | ((node: N) => HandleDescriptor<N>[])
|
||||
@@ -1,7 +1,9 @@
|
||||
export {
|
||||
discoverPlugins,
|
||||
getSelectableKinds,
|
||||
isRegistryMovable,
|
||||
isRegistrySelectable,
|
||||
kindsWithFloorplanScope,
|
||||
loadPlugin,
|
||||
nodeRegistry,
|
||||
type PluginDiscovery,
|
||||
@@ -15,6 +17,21 @@ export {
|
||||
collectDescendants,
|
||||
type SpatialQuery,
|
||||
} from './relations-resolver'
|
||||
export type {
|
||||
ArcResizeHandle,
|
||||
Cursor,
|
||||
EditorApi,
|
||||
EndpointMoveHandle,
|
||||
HandleAnchor,
|
||||
HandleAxis,
|
||||
HandleDescriptor,
|
||||
HandleList,
|
||||
HandlePlacement,
|
||||
HandlePortal,
|
||||
LinearResizeHandle,
|
||||
RadialResizeHandle,
|
||||
TapActionHandle,
|
||||
} from './handles'
|
||||
export { createSceneApi, type SceneStoreLike } from './scene-api'
|
||||
export type {
|
||||
Affordance,
|
||||
|
||||
@@ -3,6 +3,23 @@ import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types'
|
||||
|
||||
const HOST_API_VERSION = 1 as const
|
||||
|
||||
// True in dev / test builds, false in production. Tries Vite's
|
||||
// `import.meta.env.DEV` first (the editor app's bundler) and falls back
|
||||
// to `process.env.NODE_ENV !== 'production'` for Node test runners.
|
||||
function isDevMode(): boolean {
|
||||
try {
|
||||
const meta = import.meta as { env?: { DEV?: boolean } }
|
||||
if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV
|
||||
} catch {
|
||||
// import.meta unavailable in some CJS contexts — fall through.
|
||||
}
|
||||
if (typeof process !== 'undefined' && process.env?.NODE_ENV) {
|
||||
return process.env.NODE_ENV !== 'production'
|
||||
}
|
||||
// No environment signal — be safe and treat as production.
|
||||
return false
|
||||
}
|
||||
|
||||
class NodeRegistryImpl implements NodeRegistry {
|
||||
private readonly defs = new Map<string, AnyNodeDefinition>()
|
||||
|
||||
@@ -28,9 +45,6 @@ class NodeRegistryImpl implements NodeRegistry {
|
||||
|
||||
// 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')
|
||||
}
|
||||
@@ -39,6 +53,21 @@ class NodeRegistryImpl implements NodeRegistry {
|
||||
`[registry] NodeDefinition.schemaVersion must be a positive integer (kind: "${def.kind}")`,
|
||||
)
|
||||
}
|
||||
// Duplicate-kind handling depends on environment:
|
||||
// - **Production**: throw. The plugin-authoring contract
|
||||
// (`wiki/architecture/plugin-authoring.md`) guarantees that two
|
||||
// plugins shipping `kind: 'couch'` is a startup-time error, not
|
||||
// a silent overwrite — collisions need to be visible.
|
||||
// - **Dev (HMR)**: replace with a warning. Saving `def.ts` would
|
||||
// otherwise either crash on re-execute or skip it entirely,
|
||||
// leaving stale descriptors pinned in memory.
|
||||
if (this.defs.has(def.kind)) {
|
||||
if (isDevMode()) {
|
||||
console.warn(`[registry] re-registering node kind "${def.kind}" (HMR)`)
|
||||
} else {
|
||||
throw new Error(`[registry] duplicate node kind: "${def.kind}" already registered`)
|
||||
}
|
||||
}
|
||||
this.defs.set(def.kind, def)
|
||||
}
|
||||
|
||||
@@ -85,6 +114,38 @@ export function isRegistrySelectable(kind: string): boolean {
|
||||
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Kinds whose `def.floorplanScope` matches the requested scope. Used by
|
||||
* `FloorplanRegistryLayer` to discover building-scoped kinds (e.g.
|
||||
* elevator) without hardcoding kind names in the editor layer. `'level'`
|
||||
* is the default, so `kindsWithFloorplanScope('level')` includes kinds
|
||||
* that didn't set the field at all.
|
||||
*/
|
||||
export function kindsWithFloorplanScope(scope: 'level' | 'building'): string[] {
|
||||
const result: string[] = []
|
||||
for (const [kind, def] of nodeRegistry.entries()) {
|
||||
const declared = def.floorplanScope ?? 'level'
|
||||
if (declared === scope) result.push(kind)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the kind is movable from a 2D floor-plan handle —
|
||||
* either via `capabilities.movable`, an explicit
|
||||
* `def.floorplanMoveTarget`, or an `affordanceTools.move` 3D mover that
|
||||
* the floating action menu can engage. Replaces the kind-name ternary
|
||||
* chain in `floating-action-menu.tsx`.
|
||||
*/
|
||||
export function isRegistryMovable(kind: string): boolean {
|
||||
const def = nodeRegistry.get(kind)
|
||||
if (!def) return false
|
||||
if (def.capabilities.movable !== undefined) return true
|
||||
if (def.floorplanMoveTarget !== undefined) return true
|
||||
if (def.affordanceTools?.move !== undefined) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export async function loadPlugin(plugin: Plugin): Promise<void> {
|
||||
if (plugin.apiVersion !== HOST_API_VERSION) {
|
||||
throw new Error(
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeNode(kind: string, idStr: string, extra: Partial<AnyNode> = {}): An
|
||||
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
|
||||
return {
|
||||
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
||||
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
update: () => {},
|
||||
upsert: () => id(''),
|
||||
delete: () => {},
|
||||
|
||||
@@ -50,6 +50,10 @@ export function createSceneApi(store: SceneStoreLike): SceneApi {
|
||||
return store.getState().nodes[id] as N | undefined
|
||||
},
|
||||
|
||||
nodes() {
|
||||
return store.getState().nodes
|
||||
},
|
||||
|
||||
update(id, patch) {
|
||||
captureIfNeeded(id)
|
||||
store.getState().updateNode(id, patch)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BufferGeometry, Object3D } from 'three'
|
||||
import type { ZodObject, z } from 'zod'
|
||||
import type { MaterialSchema } from '../schema/material'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import type { HandleList } from './handles'
|
||||
|
||||
// ─── GeometryContext ─────────────────────────────────────────────────
|
||||
//
|
||||
@@ -146,6 +147,24 @@ export type FloorplanStyle = {
|
||||
strokeLinejoin?: 'miter' | 'round' | 'bevel'
|
||||
strokeOpacity?: number
|
||||
fillOpacity?: number
|
||||
/**
|
||||
* SVG `pointer-events`. Default (undefined) lets the renderer pick its
|
||||
* normal behaviour — `visiblePainted` for filled shapes, `stroke` for
|
||||
* line / hit-line. Set `'none'` to make a primitive completely
|
||||
* passthrough — useful for chrome that should be visible but never
|
||||
* trigger selection or drag (e.g. a wall's body once it's already
|
||||
* selected, where only the side-arrows / corner handles should grab
|
||||
* the pointer).
|
||||
*/
|
||||
pointerEvents?: 'none' | 'auto' | 'all' | 'stroke' | 'fill' | 'visible' | 'visiblePainted'
|
||||
/**
|
||||
* CSS `cursor` for the rendered primitive. Defaults to inheriting the
|
||||
* registry entry wrapper's `cursor: 'pointer'`. Override to neutralise
|
||||
* a hover affordance — e.g. a selected wall body that catches the
|
||||
* pointer (to block fall-through to the slab below) but should not
|
||||
* advertise itself as a drag target.
|
||||
*/
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
// ─── ToolHint ────────────────────────────────────────────────────────
|
||||
@@ -272,6 +291,12 @@ export type FloorplanGeometry =
|
||||
/** Stroke width in screen pixels — converted to plan units by the dispatcher. */
|
||||
strokeWidthPx: number
|
||||
cursor?: string
|
||||
/**
|
||||
* Override the default `pointer-events="stroke"`. Use `'none'` when
|
||||
* a kind wants to keep the line painted (for hit-debugging or layout
|
||||
* stability) but route grabs through other affordances instead.
|
||||
*/
|
||||
pointerEvents?: 'none' | 'stroke' | 'auto'
|
||||
}
|
||||
/**
|
||||
* Endpoint manipulation handle — the 5-circle stack from the legacy
|
||||
@@ -341,6 +366,50 @@ export type FloorplanGeometry =
|
||||
kind: 'move-handle'
|
||||
point: FloorplanPoint
|
||||
}
|
||||
/**
|
||||
* Directional move handle drawn as an arrow pointing AWAY from the
|
||||
* owning node, rotated by `angle` (radians; 0 = +x). Used by wall to
|
||||
* place two arrows on perpendicular sides at the wall midpoint —
|
||||
* mirrors the 3D `WallMoveSideHandles`. Routes through the same
|
||||
* `onMoveHandlePointerDown` → `setMovingNode` path as `move-handle`.
|
||||
*/
|
||||
| {
|
||||
kind: 'move-arrow'
|
||||
point: FloorplanPoint
|
||||
/** Rotation in radians; 0 points along +x in plan coords. */
|
||||
angle: number
|
||||
/**
|
||||
* Optional affordance routing. When set, pointer-down on the arrow
|
||||
* starts a `def.floorplanAffordances?.[affordance]` session with the
|
||||
* given `payload` (same dispatch path as `edge-handle`) instead of
|
||||
* the default `setMovingNode` flow. Used by doors for the in-plane
|
||||
* width-resize handles that visually mirror the move arrow shape but
|
||||
* drive a different mutation.
|
||||
*/
|
||||
affordance?: string
|
||||
payload?: unknown
|
||||
}
|
||||
/**
|
||||
* Curved two-headed rotation arrow — the 2D counterpart of the 3D
|
||||
* `arc-resize` handle's `shape: 'rotate'` gizmo. Visually a short arc
|
||||
* with arrowheads at each end pointing tangentially in opposite
|
||||
* directions, so it reads as "rotate either way" rather than "drag
|
||||
* along a line." Always routes through an affordance (rotation has no
|
||||
* sensible default Move semantics).
|
||||
*
|
||||
* `angle` is the radial-outward direction in plan coords — the icon's
|
||||
* local +X axis points away from the pivot, with the arc curving
|
||||
* around it. Emitters typically compute this as
|
||||
* `atan2(handle.y − pivot.y, handle.x − pivot.x)`.
|
||||
*/
|
||||
| {
|
||||
kind: 'rotate-arrow'
|
||||
point: FloorplanPoint
|
||||
/** Radial-outward direction from the rotation pivot, in radians. */
|
||||
angle: number
|
||||
affordance: string
|
||||
payload?: unknown
|
||||
}
|
||||
/**
|
||||
* Centered length / distance label. Renders as a small rounded
|
||||
* background plate with text, oriented along `angle` (radians). The
|
||||
@@ -418,20 +487,40 @@ 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.
|
||||
* Run a single drag tick. Two patterns are supported:
|
||||
* - **Scene-write preview**: implementation calls `scene.updateNodes`
|
||||
* each tick; the dispatcher captures a pre-drag snapshot and runs
|
||||
* a single-undo dance on commit (revert → resume → re-apply diff).
|
||||
* Suitable for affordances whose commit is a pure diff of the
|
||||
* affected fields.
|
||||
* - **Live-override preview**: implementation publishes per-frame
|
||||
* overrides to `useLiveNodeOverrides` (or another preview store);
|
||||
* `useScene` stays untouched during the drag. The session must
|
||||
* also expose `commit()` below, since there's no scene diff for
|
||||
* the dispatcher to write back.
|
||||
*
|
||||
* 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).
|
||||
* Called on pointer-up. Return `true` if the drag should commit;
|
||||
* `false` reverts to the snapshot (e.g. wall too short, vertex
|
||||
* collapsed onto neighbour).
|
||||
*/
|
||||
canCommit(): boolean
|
||||
/**
|
||||
* Optional atomic-commit hook — mirror of the same field on
|
||||
* `FloorplanMoveTargetSession`. When present, the dispatcher
|
||||
* reverts to the pre-drag baseline (no-op if `apply()` never wrote
|
||||
* to scene), resumes history, then calls `commit()` instead of
|
||||
* re-applying a diff. The session owns the full final write
|
||||
* (typically `applyNodeChanges` or `updateNodes`) plus clearing any
|
||||
* live overrides it published in `apply()`.
|
||||
*/
|
||||
commit?(): void
|
||||
}
|
||||
|
||||
export type FloorplanAffordance<N> = {
|
||||
@@ -493,6 +582,23 @@ export type FloorplanMoveTargetSession = {
|
||||
* area, overlap detected, ...).
|
||||
*/
|
||||
canCommit(): boolean
|
||||
/**
|
||||
* Optional atomic-commit hook. The default overlay path snapshots
|
||||
* each affected node before drag and writes a diff back on commit —
|
||||
* fine for kinds whose commit is a pure position update, but
|
||||
* insufficient when commit needs to also create or delete nodes
|
||||
* (e.g. wall move emits bridge wall creates + collapsed wall deletes
|
||||
* via `planWallMoveJunctions`).
|
||||
*
|
||||
* When present, the overlay reverts to the pre-drag baseline,
|
||||
* resumes history, and calls `commit()` instead of the default
|
||||
* `updateNodes(finalUpdates)`. The session is responsible for the
|
||||
* full final write (typically `applyNodeChanges`) plus any
|
||||
* post-commit selection / metadata. The overlay still emits the
|
||||
* standard place SFX and clears `movingNode` after `commit()`
|
||||
* returns.
|
||||
*/
|
||||
commit?(): void
|
||||
}
|
||||
|
||||
export type FloorplanMoveTarget<N> = (args: {
|
||||
@@ -592,6 +698,18 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
* the legacy `floorplan-panel.tsx` monolith.
|
||||
*/
|
||||
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
|
||||
/**
|
||||
* Which scope the floor-plan layer walks to find instances of this
|
||||
* kind. Default `'level'` — the layer's DFS from the active level id
|
||||
* picks the node up via its parent chain. `'building'` — the kind
|
||||
* lives as a sibling of levels (elevator is the canonical example:
|
||||
* elevators are parented to the *building*, not a level, but the
|
||||
* floor-plan should still surface them for every level inside that
|
||||
* building). For `'building'`-scoped kinds the layer iterates every
|
||||
* instance whose parent matches the active level's building, and
|
||||
* synthesises a `GeometryContext` whose `parent` is the active level.
|
||||
*/
|
||||
floorplanScope?: 'level' | 'building'
|
||||
/**
|
||||
* 2D drag affordances keyed by the string identifier emitted on
|
||||
* `endpoint-handle` (and similar interactive floor-plan primitives) via
|
||||
@@ -622,6 +740,30 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
* unset and rely on the generic overlay path.
|
||||
*/
|
||||
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
|
||||
/**
|
||||
* Optional hook letting a kind project the `useLiveNodeOverrides` map
|
||||
* into a fresh `nodes` snapshot before its `def.floorplan` builder
|
||||
* runs. The floor-plan layer calls this when present and passes the
|
||||
* returned map both as the builder's `ctx` source AND as the
|
||||
* effective node (so the kind's own override lands in `effectiveNode`).
|
||||
*
|
||||
* Used by wall, whose miter joins read sibling walls via
|
||||
* `ctx.siblings`: during a 2D drag the moved wall + its linked
|
||||
* neighbours publish per-frame `{ start, end, curveOffset }`
|
||||
* overrides, and the floor-plan must merge those into every wall
|
||||
* the builder can see — otherwise miter math snaps back to the
|
||||
* committed positions while the cursor moves. Kinds whose previews
|
||||
* are self-contained leave this unset and the layer hands the raw
|
||||
* `nodes` through.
|
||||
*
|
||||
* Return the input `nodes` unchanged when no override is relevant
|
||||
* so the caller can short-circuit.
|
||||
*/
|
||||
floorplanSiblingOverrides?: (args: {
|
||||
nodeId: AnyNodeId
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
liveOverrides: Map<string, Record<string, unknown>>
|
||||
}) => Record<AnyNodeId, AnyNode>
|
||||
system?: SystemContribution
|
||||
tool?: LazyComponent
|
||||
/**
|
||||
@@ -679,6 +821,24 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
* capability too).
|
||||
*/
|
||||
keyboardActions?: KeyboardActions
|
||||
|
||||
/**
|
||||
* In-world resize / move arrows shown when this kind is selected.
|
||||
*
|
||||
* Pure descriptors — no React, no Three.js. The editor's generic
|
||||
* `<NodeArrowHandles>` reads this list and mounts the matching arrow
|
||||
* components with shared drag plumbing, replacing per-kind
|
||||
* `<XxxSideHandles>` files for the common cases.
|
||||
*
|
||||
* Static array, or a function for shape-dependent affordances
|
||||
* (column `crossSection` / `supportStyle`, stair-segment `segmentType`,
|
||||
* curved-vs-straight stairs). See `./handles.ts` for the variant union.
|
||||
*
|
||||
* Bespoke chrome that doesn't fit the descriptor model (wall corner
|
||||
* leader dashes, fence curving, items with `attachTo`) stays as a
|
||||
* custom React component mounted alongside.
|
||||
*/
|
||||
handles?: HandleList<z.infer<S>>
|
||||
}
|
||||
|
||||
export type NodeCategory = 'site' | 'structure' | 'furnish' | 'analysis' | 'utility'
|
||||
@@ -790,6 +950,26 @@ export type Capabilities = {
|
||||
floorPlaced?: FloorPlacedConfig
|
||||
roofAccessory?: RoofAccessoryConfig
|
||||
paint?: PaintCapability
|
||||
/**
|
||||
* Kind is placed by clicking on a wall (door, window). When set, the
|
||||
* floor-plan layer lets wall background clicks pass through during
|
||||
* placement / move-on-wall — the placement tool's `wall:click` event
|
||||
* needs the SVG's `findClosestWallPoint` handler to run; without
|
||||
* this the wall's registry entry would swallow the click via
|
||||
* `handleSelect`. Read by `FloorplanRegistryLayer` when `movingNode`
|
||||
* is set, so the active move can suspend wall selection.
|
||||
*/
|
||||
wallOpeningPlacement?: boolean
|
||||
/**
|
||||
* Instances of this kind contain levels. When such a node is being
|
||||
* moved, the floor-plan layer falls back to the moving node's id as
|
||||
* the ambient building context — so the floor under the cursor keeps
|
||||
* rendering dimmed throughout the gesture even though the explicit
|
||||
* selection may have been cleared as part of the move handoff. Set
|
||||
* on building; future container kinds (e.g. annexes) opt in by
|
||||
* declaring the same flag.
|
||||
*/
|
||||
floorplanLevelContainer?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1086,6 +1266,14 @@ export type SnapServicesLike = unknown
|
||||
|
||||
export type SceneApi = {
|
||||
get: <N extends AnyNode = AnyNode>(id: AnyNodeId) => N | undefined
|
||||
/**
|
||||
* Snapshot of the full nodes record. For descriptors / placement
|
||||
* callbacks that need to walk many siblings or resolve cross-node
|
||||
* structure (elevator level entries, building level chains, etc.)
|
||||
* without N round-trips through `get`. Returns the live reference —
|
||||
* do not mutate.
|
||||
*/
|
||||
nodes: () => Readonly<Record<AnyNodeId, AnyNode>>
|
||||
update: (id: AnyNodeId, patch: Partial<AnyNode>) => void
|
||||
upsert: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId
|
||||
delete: (id: AnyNodeId) => void
|
||||
|
||||
@@ -26,6 +26,7 @@ function makeSpyScene(initial: Record<string, AnyNode> = {}): SceneApi & {
|
||||
const nodes = { ...initial }
|
||||
return {
|
||||
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
||||
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
update: (nid, patch) => {
|
||||
calls.updated.push([nid, patch])
|
||||
const existing = nodes[nid as string]
|
||||
|
||||
@@ -43,6 +43,7 @@ function makeNode(kind: string, idStr: string, parentId: string | null = null):
|
||||
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
|
||||
return {
|
||||
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
||||
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
update: () => {},
|
||||
upsert: () => id(''),
|
||||
delete: () => {},
|
||||
|
||||
@@ -5,6 +5,7 @@ export type LiveNodeOverrides = Record<string, unknown>
|
||||
type LiveNodeOverrideState = {
|
||||
overrides: Map<string, LiveNodeOverrides>
|
||||
set(nodeId: string, values: LiveNodeOverrides): void
|
||||
setMany(entries: ReadonlyArray<readonly [string, LiveNodeOverrides]>): void
|
||||
get(nodeId: string): LiveNodeOverrides | undefined
|
||||
clear(nodeId: string): void
|
||||
clearAll(): void
|
||||
@@ -18,6 +19,19 @@ const useLiveNodeOverrides = create<LiveNodeOverrideState>((set, get) => ({
|
||||
next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values })
|
||||
return { overrides: next }
|
||||
}),
|
||||
// Batch update — one Map clone + one zustand notification regardless
|
||||
// of entry count, so a drag publishing to N linked walls re-renders
|
||||
// subscribers (WallSystem, FloorplanRegistryLayer) once per tick
|
||||
// instead of N+1 times.
|
||||
setMany: (entries) =>
|
||||
set((state) => {
|
||||
if (entries.length === 0) return state
|
||||
const next = new Map(state.overrides)
|
||||
for (const [nodeId, values] of entries) {
|
||||
next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values })
|
||||
}
|
||||
return { overrides: next }
|
||||
}),
|
||||
get: (nodeId) => get().overrides.get(nodeId),
|
||||
clear: (nodeId) =>
|
||||
set((state) => {
|
||||
@@ -28,4 +42,16 @@ const useLiveNodeOverrides = create<LiveNodeOverrideState>((set, get) => ({
|
||||
clearAll: () => set({ overrides: new Map() }),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Merge any live override for `node` into a fresh copy. Spread semantics —
|
||||
* override fields win, untouched fields stay. Returns the input unchanged
|
||||
* when no override exists, so the caller can use the result directly
|
||||
* without an extra "did anything change" check.
|
||||
*/
|
||||
export function getEffectiveNode<T extends { id: string }>(node: T): T {
|
||||
const override = useLiveNodeOverrides.getState().overrides.get(node.id)
|
||||
if (!override || Object.keys(override).length === 0) return node
|
||||
return { ...node, ...override } as T
|
||||
}
|
||||
|
||||
export default useLiveNodeOverrides
|
||||
|
||||
@@ -101,6 +101,88 @@ function getMoveWallRelation(
|
||||
return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction'
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a junction plan to a list of linked walls, producing the per-wall
|
||||
* endpoint updates. Mirrors the 3D `MoveWallTool`'s drag-preview behavior
|
||||
* so the 2D move handler can drive the same scene topology.
|
||||
*
|
||||
* `linkedWallTargetPlans` take precedence over `linkedWallsToMove` — when
|
||||
* the planner emits a target plan for a same-direction-consumed wall the
|
||||
* matchPoint/targetPoint pair encodes the pivot, not the original ↔ next
|
||||
* endpoint mapping.
|
||||
*/
|
||||
export function getLinkedWallUpdates<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
|
||||
linkedWalls: Array<{
|
||||
wall: TWall
|
||||
matchPoint?: WallPlanPoint
|
||||
targetPoint?: WallPlanPoint
|
||||
}>,
|
||||
originalStart: WallPlanPoint,
|
||||
originalEnd: WallPlanPoint,
|
||||
nextStart: WallPlanPoint,
|
||||
nextEnd: WallPlanPoint,
|
||||
): Array<{ id: TWall['id']; start: WallPlanPoint; end: WallPlanPoint }> {
|
||||
return linkedWalls.map(({ wall, matchPoint, targetPoint }) => {
|
||||
if (matchPoint && targetPoint) {
|
||||
return {
|
||||
id: wall.id,
|
||||
start: pointsEqual(wall.start, matchPoint) ? targetPoint : wall.start,
|
||||
end: pointsEqual(wall.end, matchPoint) ? targetPoint : wall.end,
|
||||
}
|
||||
}
|
||||
|
||||
const targetStart = targetPoint ?? nextStart
|
||||
const targetEnd = targetPoint ?? nextEnd
|
||||
|
||||
return {
|
||||
id: wall.id,
|
||||
start: pointsEqual(wall.start, originalStart)
|
||||
? targetStart
|
||||
: pointsEqual(wall.start, originalEnd)
|
||||
? targetEnd
|
||||
: wall.start,
|
||||
end: pointsEqual(wall.end, originalStart)
|
||||
? targetStart
|
||||
: pointsEqual(wall.end, originalEnd)
|
||||
? targetEnd
|
||||
: wall.end,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getPlannedLinkedWallUpdates<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
|
||||
plan: WallMoveJunctionPlan<TWall>,
|
||||
originalStart: WallPlanPoint,
|
||||
originalEnd: WallPlanPoint,
|
||||
nextStart: WallPlanPoint,
|
||||
nextEnd: WallPlanPoint,
|
||||
): Array<{ id: TWall['id']; start: WallPlanPoint; end: WallPlanPoint }> {
|
||||
const movePlans = new Map<
|
||||
TWall['id'],
|
||||
{ wall: TWall; matchPoint?: WallPlanPoint; targetPoint?: WallPlanPoint }
|
||||
>()
|
||||
|
||||
for (const wall of plan.linkedWallsToMove) {
|
||||
movePlans.set(wall.id, { wall })
|
||||
}
|
||||
|
||||
for (const targetPlan of plan.linkedWallTargetPlans) {
|
||||
movePlans.set(targetPlan.wall.id, {
|
||||
wall: targetPlan.wall,
|
||||
matchPoint: targetPlan.originalPoint,
|
||||
targetPoint: targetPlan.targetPoint,
|
||||
})
|
||||
}
|
||||
|
||||
return getLinkedWallUpdates(
|
||||
Array.from(movePlans.values()),
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)
|
||||
}
|
||||
|
||||
export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
|
||||
linkedWalls: TWall[],
|
||||
originalStart: WallPlanPoint,
|
||||
|
||||
Reference in New Issue
Block a user