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
@@ -14,7 +14,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
getWallGridStep,
|
||||
getSegmentGridStep,
|
||||
markToolCancelConsumed,
|
||||
snapScalarToGrid,
|
||||
triggerSFX,
|
||||
@@ -84,7 +84,7 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const snapStep = getWallGridStep()
|
||||
const snapStep = getSegmentGridStep()
|
||||
const localX = shiftPressedRef.current
|
||||
? event.localPosition[0]
|
||||
: snapScalarToGrid(event.localPosition[0], snapStep)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildWallFloorplan } from './floorplan'
|
||||
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
|
||||
import { wallFloorplanMoveTarget } from './floorplan-move'
|
||||
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
|
||||
import { wallPaint } from './paint'
|
||||
import { wallParametrics } from './parametrics'
|
||||
import { WallNode } from './schema'
|
||||
@@ -64,6 +66,9 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
},
|
||||
|
||||
parametrics: wallParametrics,
|
||||
// Height arrow + side-move arrows + corner pickers all live in the legacy
|
||||
// `wall-move-side-handles.tsx` component. The registry handle path didn't
|
||||
// render correctly for walls specifically; revisit once that's diagnosed.
|
||||
|
||||
// Stage D — all four wall drag affordances live in this folder.
|
||||
// curve / move-endpoint / move are 1:1 ports of the legacy tools
|
||||
@@ -98,6 +103,8 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
'move-endpoint': wallMoveEndpointAffordance,
|
||||
curve: wallCurveAffordance,
|
||||
},
|
||||
floorplanMoveTarget: wallFloorplanMoveTarget,
|
||||
floorplanSiblingOverrides: wallFloorplanSiblingOverrides,
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Set wall start / end' },
|
||||
|
||||
@@ -6,14 +6,16 @@ import {
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
normalizeWallCurveOffset,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
getWallGridStep,
|
||||
isWallLongEnough,
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
snapScalarToGrid,
|
||||
snapWallDraftPoint,
|
||||
WALL_FINE_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
@@ -29,9 +31,13 @@ import {
|
||||
* ("linked walls").
|
||||
* 2. On each tick: snap the moving point (grid → linked-wall → angle),
|
||||
* compute primary endpoints, and cascade matching corners onto the
|
||||
* linked walls. Apply via `useScene.updateNodes` — the registry
|
||||
* dispatcher pauses / resumes history around this.
|
||||
* 3. Commit if the resulting wall is still long enough (legacy guard).
|
||||
* linked walls. Publish to `useLiveNodeOverrides` — `WallSystem`,
|
||||
* the 2D floor-plan layer, and the sidebar panel all merge the
|
||||
* overrides in when reading endpoints, so `useScene` never sees a
|
||||
* mid-drag write.
|
||||
* 3. On pointer-up: the dispatcher invokes `commit()`, which writes
|
||||
* the final state to scene in one tracked update and clears the
|
||||
* overrides. `canCommit` still guards against collapsed walls.
|
||||
*
|
||||
* Alt-detach (drop linked walls) and SHIFT-free-place (skip angle snap)
|
||||
* are wired via the standard modifier flags on the session.
|
||||
@@ -96,11 +102,13 @@ export const wallCurveAffordance: FloorplanAffordance<WallNode> = {
|
||||
// pointer projection along its normal changes.
|
||||
const chord = getWallChordFrame(node)
|
||||
const maxOffset = getMaxWallCurveOffset(node)
|
||||
const wallId = node.id as AnyNodeId
|
||||
let lastCurveOffset = node.curveOffset ?? 0
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapStep = getWallGridStep()
|
||||
const snapStep = getSegmentGridStep()
|
||||
const x = modifiers.shiftKey ? planPoint[0] : snapScalarToGrid(planPoint[0], snapStep)
|
||||
const y = modifiers.shiftKey ? planPoint[1] : snapScalarToGrid(planPoint[1], snapStep)
|
||||
|
||||
@@ -121,14 +129,27 @@ export const wallCurveAffordance: FloorplanAffordance<WallNode> = {
|
||||
node,
|
||||
Math.max(-maxOffset, Math.min(maxOffset, snappedOffset)),
|
||||
)
|
||||
lastCurveOffset = nextCurveOffset
|
||||
|
||||
useScene.getState().updateNodes([{ id: node.id, data: { curveOffset: nextCurveOffset } }])
|
||||
// Publish the curve preview as a live override so renderers see
|
||||
// it without zustand churn. Mark the wall dirty so `WallSystem`
|
||||
// rebuilds the geometry next frame using the override-merged
|
||||
// node.
|
||||
useLiveNodeOverrides.getState().set(wallId, { curveOffset: nextCurveOffset })
|
||||
useScene.getState().markDirty(wallId)
|
||||
},
|
||||
canCommit() {
|
||||
// Curve drag is always commit-eligible — the offset is already
|
||||
// clamped + normalized so we never end up in an invalid state.
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
// Atomic, tracked write of the final curve offset, then drop
|
||||
// the override so the scene state is the single source of
|
||||
// truth again.
|
||||
useScene.getState().updateNodes([{ id: wallId, data: { curveOffset: lastCurveOffset } }])
|
||||
useLiveNodeOverrides.getState().clear(wallId)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -143,20 +164,31 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
|
||||
const linkedWalls = collectLinkedWalls(nodes, node.id, originalStart, originalEnd)
|
||||
const affectedIds: AnyNodeId[] = [node.id, ...linkedWalls.map((w) => w.id)]
|
||||
|
||||
// Remember the latest preview so `commit()` can write it tracked.
|
||||
let lastPrimaryStart: WallPlanPoint = originalStart
|
||||
let lastPrimaryEnd: WallPlanPoint = originalEnd
|
||||
let lastLinkedUpdates: Array<{ id: AnyNodeId; start: WallPlanPoint; end: WallPlanPoint }> = []
|
||||
|
||||
return {
|
||||
affectedIds,
|
||||
apply({ planPoint, modifiers }) {
|
||||
// Re-collect walls every tick so the snap pipeline sees fresh
|
||||
// positions (matters when the user releases + re-grabs without
|
||||
// unmounting the layer).
|
||||
// unmounting the layer). Snap reads from scene — which holds
|
||||
// the pre-drag positions throughout — so the linked-wall snap
|
||||
// targets stay anchored to where corners *were*, exactly like
|
||||
// the legacy flow.
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
const walls = collectLevelWalls(sceneNodes, node.id)
|
||||
// Endpoint move = grid snap, never 45° from the fixed corner —
|
||||
// the angle snap is for initial draft only. Shift switches to
|
||||
// the fine grid step for precision, matching the 3D
|
||||
// `MoveWallEndpointTool`.
|
||||
const snapped = snapWallDraftPoint({
|
||||
point: planPoint as WallPlanPoint,
|
||||
walls,
|
||||
start: fixedPoint,
|
||||
angleSnap: !modifiers.shiftKey,
|
||||
ignoreWallIds: [node.id],
|
||||
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
|
||||
const primaryStart: WallPlanPoint = endpoint === 'start' ? snapped : fixedPoint
|
||||
@@ -180,21 +212,42 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
|
||||
: w.end,
|
||||
}))
|
||||
|
||||
lastPrimaryStart = primaryStart
|
||||
lastPrimaryEnd = primaryEnd
|
||||
lastLinkedUpdates = linkedUpdates
|
||||
|
||||
// Publish overrides instead of writing to scene. WallSystem +
|
||||
// 2D layer + sidebar panel merge these in. Marking dirty
|
||||
// wakes the system's `useFrame` rebuild pass.
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
const sceneState = useScene.getState()
|
||||
overrides.set(node.id as AnyNodeId, { start: primaryStart, end: primaryEnd })
|
||||
sceneState.markDirty(node.id as AnyNodeId)
|
||||
for (const upd of linkedUpdates) {
|
||||
overrides.set(upd.id, { start: upd.start, end: upd.end })
|
||||
sceneState.markDirty(upd.id)
|
||||
}
|
||||
},
|
||||
canCommit() {
|
||||
// The dragged wall must still be long enough at the preview
|
||||
// length — checked against `lastPrimary*`, not scene, because
|
||||
// scene holds baseline values until commit().
|
||||
return isSegmentLongEnough(lastPrimaryStart, lastPrimaryEnd)
|
||||
},
|
||||
commit() {
|
||||
// Atomic tracked write of the final endpoints, then drop the
|
||||
// overrides so the scene state is the single source of truth
|
||||
// again.
|
||||
useScene.getState().updateNodes([
|
||||
{ id: node.id, data: { start: primaryStart, end: primaryEnd } },
|
||||
...linkedUpdates.map((u) => ({
|
||||
{ id: node.id, data: { start: lastPrimaryStart, end: lastPrimaryEnd } },
|
||||
...lastLinkedUpdates.map((u) => ({
|
||||
id: u.id,
|
||||
data: { start: u.start, end: u.end },
|
||||
})),
|
||||
])
|
||||
},
|
||||
canCommit() {
|
||||
const finalWall = useScene.getState().nodes[node.id] as WallNode | undefined
|
||||
return (
|
||||
!!finalWall &&
|
||||
finalWall.type === 'wall' &&
|
||||
isWallLongEnough(finalWall.start, finalWall.end)
|
||||
)
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
overrides.clear(node.id as AnyNodeId)
|
||||
for (const upd of lastLinkedUpdates) overrides.clear(upd.id)
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
getPerpendicularWallMoveAxis,
|
||||
getPlannedLinkedWallUpdates,
|
||||
planWallMoveJunctions,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
getFloorplanWallThickness,
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
snapScalarToGrid,
|
||||
useWallMoveGhosts,
|
||||
type WallMoveGhostBridge,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
buildBridgeWallCreates,
|
||||
buildBridgeWallPreviews,
|
||||
getLinkedWallSnapshots,
|
||||
getWallsAfterUpdates,
|
||||
type LinkedWallSnapshot,
|
||||
stripWallIsNewMetadata,
|
||||
} from './move-shared'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for wall.
|
||||
*
|
||||
* Mirrors the 3D `MoveWallTool` junction-plan behavior so dragging a
|
||||
* wall in the floor plan produces the same scene topology as dragging
|
||||
* it in 3D: linked corners cascade, same-direction collapsed walls
|
||||
* delete, off-axis branches stay rectilinear with a new bridge wall
|
||||
* inserted between the original and new corner.
|
||||
*
|
||||
* Tick (`apply`) — publishes per-frame `{ start, end }` overrides to
|
||||
* `useLiveNodeOverrides` for the moved wall + every linked wall.
|
||||
* `useScene` stays at the pre-drag values throughout the drag; the
|
||||
* `WallSystem` and the 2D floor-plan layer fold the overrides in when
|
||||
* reading endpoints, so the visual preview updates without churning
|
||||
* zustand. Bridge creates and wall deletes are still deferred to
|
||||
* commit so the live preview doesn't churn the scene graph either.
|
||||
*
|
||||
* Commit (`commit`) — recomputes the plan at the final cursor position
|
||||
* and emits one atomic `applyNodeChanges` covering the moved walls,
|
||||
* the bridge wall creates, and the collapsed wall deletes — so a
|
||||
* single Ctrl-Z rolls the entire operation back. Clears the live
|
||||
* overrides after the write lands so the system reads from the new
|
||||
* committed scene state.
|
||||
*
|
||||
* Auto-slab live preview and ghost bridge SVG previews — visible in
|
||||
* the 3D tool — are deliberately deferred. Slab polygons re-derive on
|
||||
* commit through the normal scene reactions; bridges appear at commit
|
||||
* time. Follow-up work to surface them mid-drag is tracked separately.
|
||||
*/
|
||||
export const wallFloorplanMoveTarget: FloorplanMoveTarget<WallNode> = ({ node }) => {
|
||||
const wallId = node.id as AnyNodeId
|
||||
const originalStart: WallPlanPoint = [node.start[0], node.start[1]]
|
||||
const originalEnd: WallPlanPoint = [node.end[0], node.end[1]]
|
||||
const originalCenter: WallPlanPoint = [
|
||||
(originalStart[0] + originalEnd[0]) / 2,
|
||||
(originalStart[1] + originalEnd[1]) / 2,
|
||||
]
|
||||
// Perpendicular (wall normal) axis — same lock the 3D `MoveWallTool`
|
||||
// uses, so a 2D drag slides the wall sideways across its own
|
||||
// direction rather than free-floating in XZ. `null` only for
|
||||
// degenerate zero-length walls; we fall through to free motion in
|
||||
// that case (the wall is going to be deleted anyway).
|
||||
const moveAxis = getPerpendicularWallMoveAxis(originalStart, originalEnd)
|
||||
const isNew = !!(node.metadata as { isNew?: unknown } | null)?.isNew
|
||||
|
||||
const linkedOriginals: LinkedWallSnapshot[] = isNew
|
||||
? []
|
||||
: getLinkedWallSnapshots({
|
||||
wallId: node.id,
|
||||
wallParentId: node.parentId ?? null,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
})
|
||||
|
||||
// Anchor lazily at the first cursor sample (the click point) and
|
||||
// measure cursor movement from there. The wall doesn't shift on
|
||||
// click — `rawDelta` is zero on the first apply, so the snapped
|
||||
// centre projection equals `originalProj` (already grid-aligned for
|
||||
// walls placed via the draft tool). As the cursor moves, the wall
|
||||
// translates along its normal by the cursor's perpendicular delta,
|
||||
// snapped to absolute grid lines.
|
||||
//
|
||||
// Using `originalCenter` as the anchor instead would pin the wall's
|
||||
// centre to the cursor — but the floor-plan drag icon lives inside
|
||||
// a floating menu whose hit-area is offset from the exact wall
|
||||
// centre, so the first `planPoint` would never equal `originalCenter`
|
||||
// and the wall would snap to a different grid cell the instant the
|
||||
// drag began, even before the cursor moved.
|
||||
let rawAnchor: WallPlanPoint | null = null
|
||||
let lastDelta: WallPlanPoint = [0, 0]
|
||||
let lastNextStart: WallPlanPoint = originalStart
|
||||
let lastNextEnd: WallPlanPoint = originalEnd
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [wallId, ...linkedOriginals.map((w) => w.id as AnyNodeId)],
|
||||
|
||||
apply({ planPoint, modifiers }) {
|
||||
if (!rawAnchor) {
|
||||
rawAnchor = [planPoint[0], planPoint[1]]
|
||||
return
|
||||
}
|
||||
|
||||
const rawDx = planPoint[0] - rawAnchor[0]
|
||||
const rawDz = planPoint[1] - rawAnchor[1]
|
||||
const step = getSegmentGridStep()
|
||||
|
||||
// Axis-lock the move to the wall's perpendicular (normal). Project
|
||||
// the original centre + raw cursor delta onto the axis, snap the
|
||||
// absolute projection to a grid multiple, then translate the wall
|
||||
// by `axis * perpDelta`. Matches `MoveWallTool` so 2D and 3D drag
|
||||
// produce identical wall topology. Shift bypasses snap.
|
||||
let dx: number
|
||||
let dz: number
|
||||
if (moveAxis) {
|
||||
const originalProj = originalCenter[0] * moveAxis[0] + originalCenter[1] * moveAxis[1]
|
||||
const rawProj = originalProj + rawDx * moveAxis[0] + rawDz * moveAxis[1]
|
||||
const snappedProj = modifiers.shiftKey ? rawProj : snapScalarToGrid(rawProj, step)
|
||||
const perpDelta = snappedProj - originalProj
|
||||
dx = moveAxis[0] * perpDelta
|
||||
dz = moveAxis[1] * perpDelta
|
||||
} else {
|
||||
dx = modifiers.shiftKey ? rawDx : snapScalarToGrid(rawDx, step)
|
||||
dz = modifiers.shiftKey ? rawDz : snapScalarToGrid(rawDz, step)
|
||||
}
|
||||
|
||||
if (dx === lastDelta[0] && dz === lastDelta[1]) return
|
||||
lastDelta = [dx, dz]
|
||||
|
||||
const nextStart: WallPlanPoint = [originalStart[0] + dx, originalStart[1] + dz]
|
||||
const nextEnd: WallPlanPoint = [originalEnd[0] + dx, originalEnd[1] + dz]
|
||||
lastNextStart = nextStart
|
||||
lastNextEnd = nextEnd
|
||||
|
||||
const plan = planWallMoveJunctions(
|
||||
linkedOriginals,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)
|
||||
const plannedUpdates = getPlannedLinkedWallUpdates(
|
||||
plan,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)
|
||||
const plannedById = new Map(plannedUpdates.map((entry) => [entry.id, entry]))
|
||||
|
||||
// Walls in the plan that would collapse to ≈zero-length get held
|
||||
// at their pre-drag positions during the live preview — actually
|
||||
// deleting them is deferred to commit so a single drag tick
|
||||
// doesn't churn the scene graph with create / delete pairs.
|
||||
const collapsedIds = new Set([
|
||||
...plannedUpdates
|
||||
.filter((entry) => !isSegmentLongEnough(entry.start, entry.end))
|
||||
.map((entry) => entry.id),
|
||||
...plan.wallsToDelete.map((wall) => wall.id),
|
||||
])
|
||||
|
||||
const linkedUpdates = linkedOriginals.map((wall) => {
|
||||
if (collapsedIds.has(wall.id)) {
|
||||
return { id: wall.id as AnyNodeId, start: wall.start, end: wall.end }
|
||||
}
|
||||
const planned = plannedById.get(wall.id)
|
||||
if (planned) {
|
||||
return { id: wall.id as AnyNodeId, start: planned.start, end: planned.end }
|
||||
}
|
||||
return { id: wall.id as AnyNodeId, start: wall.start, end: wall.end }
|
||||
})
|
||||
|
||||
// Publish endpoint overrides instead of writing to `useScene`.
|
||||
// `WallSystem` + the 2D layer + the sidebar panel all consult
|
||||
// `useLiveNodeOverrides` first, so the visual + numeric preview
|
||||
// updates each frame while zustand stays at the pre-drag values
|
||||
// until the user commits. Batched into a single zustand
|
||||
// notification — otherwise each per-wall `.set` would re-render
|
||||
// every override subscriber once per linked wall per tick.
|
||||
useLiveNodeOverrides.getState().setMany([
|
||||
[wallId, { start: nextStart, end: nextEnd }],
|
||||
...linkedUpdates.map(
|
||||
(upd) =>
|
||||
[upd.id, { start: upd.start, end: upd.end }] as [string, Record<string, unknown>],
|
||||
),
|
||||
])
|
||||
|
||||
// Surface bridge-wall previews so the floor-plan SVG layer can
|
||||
// render dashed outlines of what `commit()` will insert. Mirrors
|
||||
// the 3D `MoveWallTool`'s `ghostWallPreviews` mesh layer. The
|
||||
// `existingWalls` snapshot needs the post-drag layout so duplicate
|
||||
// detection works; we synthesise it from scene state + the
|
||||
// overrides we just published (zustand is still on baseline).
|
||||
const previewSceneWalls = getWallsAfterUpdates(useScene.getState().nodes, [
|
||||
{ id: wallId, data: { start: nextStart, end: nextEnd } },
|
||||
...linkedUpdates.map((u) => ({ id: u.id, data: { start: u.start, end: u.end } })),
|
||||
]).filter((wall) => !collapsedIds.has(wall.id))
|
||||
const bridgePreviews = buildBridgeWallPreviews({
|
||||
bridgePlans: plan.bridgePlans,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
existingWalls: previewSceneWalls,
|
||||
})
|
||||
const ghostBridges: WallMoveGhostBridge[] = bridgePreviews.map(({ ghost, wall }) => ({
|
||||
id: ghost.id,
|
||||
start: ghost.start,
|
||||
end: ghost.end,
|
||||
color: ghost.color,
|
||||
thickness: getFloorplanWallThickness(wall),
|
||||
}))
|
||||
useWallMoveGhosts.getState().setBridges(ghostBridges)
|
||||
|
||||
// `WallSystem` only runs its rebuild pass when `dirtyNodes` is
|
||||
// non-empty. We're not writing to scene any more, but we still
|
||||
// need the system to pick up the live override changes — so
|
||||
// mark dirty here. The rebuild reads the effective endpoints
|
||||
// (override-merged) so the mesh follows the cursor.
|
||||
const sceneState = useScene.getState()
|
||||
sceneState.markDirty(wallId)
|
||||
for (const upd of linkedUpdates) {
|
||||
sceneState.markDirty(upd.id)
|
||||
}
|
||||
},
|
||||
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[wallId] as WallNode | undefined
|
||||
if (!live || live.type !== 'wall') return false
|
||||
const [dx, dz] = lastDelta
|
||||
return dx !== 0 || dz !== 0
|
||||
},
|
||||
|
||||
commit() {
|
||||
const sceneState = useScene.getState()
|
||||
const liveWall = sceneState.nodes[wallId] as WallNode | undefined
|
||||
if (!liveWall || liveWall.type !== 'wall') {
|
||||
// Bail without leaving stale overrides behind.
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
overrides.clear(wallId)
|
||||
for (const wall of linkedOriginals) overrides.clear(wall.id as AnyNodeId)
|
||||
return
|
||||
}
|
||||
|
||||
const plan = planWallMoveJunctions(
|
||||
linkedOriginals,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
lastNextStart,
|
||||
lastNextEnd,
|
||||
)
|
||||
const linkedWallUpdates = getPlannedLinkedWallUpdates(
|
||||
plan,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
lastNextStart,
|
||||
lastNextEnd,
|
||||
)
|
||||
const collapsedLinkedWallIds = new Set([
|
||||
...linkedWallUpdates
|
||||
.filter((entry) => !isSegmentLongEnough(entry.start, entry.end))
|
||||
.map((entry) => entry.id as AnyNodeId),
|
||||
...plan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
|
||||
])
|
||||
|
||||
const movingWallUpdate = {
|
||||
id: wallId,
|
||||
data: isNew
|
||||
? {
|
||||
start: lastNextStart,
|
||||
end: lastNextEnd,
|
||||
metadata: stripWallIsNewMetadata(node.metadata),
|
||||
}
|
||||
: { start: lastNextStart, end: lastNextEnd },
|
||||
}
|
||||
|
||||
const commitUpdates: Array<{ id: AnyNodeId; data: Partial<WallNode> }> = [
|
||||
movingWallUpdate,
|
||||
...linkedWallUpdates
|
||||
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
|
||||
.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: { start: entry.start, end: entry.end } as Partial<WallNode>,
|
||||
})),
|
||||
]
|
||||
|
||||
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
|
||||
(wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId),
|
||||
)
|
||||
const bridgeCreates = buildBridgeWallCreates({
|
||||
bridgePlans: plan.bridgePlans,
|
||||
nextStart: lastNextStart,
|
||||
nextEnd: lastNextEnd,
|
||||
existingWalls,
|
||||
wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length,
|
||||
})
|
||||
|
||||
sceneState.applyNodeChanges({
|
||||
update: commitUpdates as Array<{ id: AnyNodeId; data: Partial<AnyNode> }>,
|
||||
create: bridgeCreates,
|
||||
delete: Array.from(collapsedLinkedWallIds),
|
||||
})
|
||||
|
||||
// Drop the live overrides now that the committed scene state
|
||||
// matches them — leaving them around would keep the system
|
||||
// double-merging the same values and would shadow any genuine
|
||||
// post-commit edits to start/end. Cleared synchronously so the
|
||||
// next frame reads the new scene state directly.
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
overrides.clear(wallId)
|
||||
for (const wall of linkedOriginals) overrides.clear(wall.id as AnyNodeId)
|
||||
|
||||
// Swap ghosts → real walls: the bridges we just created render
|
||||
// through the registry layer, so the dashed previews aren't
|
||||
// needed any more. Clearing here (instead of waiting for the
|
||||
// overlay's unmount cleanup) avoids a one-frame flash where
|
||||
// both ghosts and real bridges paint at the new position.
|
||||
useWallMoveGhosts.getState().clear()
|
||||
|
||||
useViewer.getState().setSelection({ selectedIds: [wallId] })
|
||||
},
|
||||
}
|
||||
return session
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Project per-frame wall drag overrides (`{ start, end, curveOffset }`)
|
||||
* from `useLiveNodeOverrides` into a fresh `nodes` snapshot. The 2D drag
|
||||
* handlers publish overrides for the moved wall plus its linked
|
||||
* neighbours; the floor-plan layer hands the merged snapshot to
|
||||
* `buildContext` so each wall's `ctx.siblings` (which feeds the
|
||||
* miter calculation) reflects the live cursor positions instead of
|
||||
* the last committed scene state.
|
||||
*
|
||||
* Only wall entries are touched; every other node is shared by
|
||||
* reference. The allocation cost is one shallow object per overridden
|
||||
* wall — the override map is small, so this is cheap. When the
|
||||
* override map is empty (no live drag) the input is returned
|
||||
* unchanged.
|
||||
*/
|
||||
export function wallFloorplanSiblingOverrides(args: {
|
||||
nodeId: AnyNodeId
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
liveOverrides: Map<string, Record<string, unknown>>
|
||||
}): Record<AnyNodeId, AnyNode> {
|
||||
const { nodes, liveOverrides } = args
|
||||
if (liveOverrides.size === 0) return nodes
|
||||
let out: Record<AnyNodeId, AnyNode> | null = null
|
||||
for (const [id, override] of liveOverrides) {
|
||||
const existing = nodes[id as AnyNodeId]
|
||||
if (!existing || existing.type !== 'wall') continue
|
||||
if (Object.keys(override).length === 0) continue
|
||||
if (!out) out = { ...nodes }
|
||||
out[id as AnyNodeId] = { ...existing, ...override } as AnyNode
|
||||
}
|
||||
return out ?? nodes
|
||||
}
|
||||
@@ -95,6 +95,12 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
|
||||
opacity: 0.92,
|
||||
// Once the wall is selected, the body keeps catching the pointer
|
||||
// so the cursor stays neutral (no drag/pointer affordance from
|
||||
// the slab below leaking through), but only the side-arrows and
|
||||
// endpoint handles should start a drag — the wrapper g's click
|
||||
// handler is a no-op re-select for the already-selected wall.
|
||||
cursor: isSelected ? 'default' : undefined,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -110,16 +116,20 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
}
|
||||
|
||||
// Hit-line on the centerline. Stroke width is in screen pixels so it
|
||||
// stays clickable at any zoom.
|
||||
children.push({
|
||||
kind: 'hit-line',
|
||||
x1: node.start[0],
|
||||
y1: node.start[1],
|
||||
x2: node.end[0],
|
||||
y2: node.end[1],
|
||||
strokeWidthPx: 18,
|
||||
cursor: 'pointer',
|
||||
})
|
||||
// stays clickable at any zoom. Skipped while selected — the user has
|
||||
// the side-arrows / endpoint handles by then, and leaving the hit-line
|
||||
// live would re-introduce a "click-and-drag the wall body" path.
|
||||
if (!isSelected) {
|
||||
children.push({
|
||||
kind: 'hit-line',
|
||||
x1: node.start[0],
|
||||
y1: node.start[1],
|
||||
x2: node.end[0],
|
||||
y2: node.end[1],
|
||||
strokeWidthPx: 18,
|
||||
cursor: 'pointer',
|
||||
})
|
||||
}
|
||||
|
||||
// Endpoint handles only when the user has actively selected this wall.
|
||||
if (isSelected) {
|
||||
@@ -138,6 +148,34 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
payload: { wallId: node.id, endpoint: 'end' as const },
|
||||
})
|
||||
|
||||
// Side move arrows — two directional arrows at the wall midpoint,
|
||||
// pointing outward perpendicular to the wall. Mirrors the 3D
|
||||
// `WallMoveSideHandles` arrows so users can grab the wall body
|
||||
// from the floor plan. PointerDown on either arrow activates
|
||||
// `wallFloorplanMoveTarget` via the registry-layer dispatcher.
|
||||
{
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
const wallLength = Math.hypot(dx, dz)
|
||||
if (wallLength > 1e-6) {
|
||||
const midX = (node.start[0] + node.end[0]) / 2
|
||||
const midZ = (node.start[1] + node.end[1]) / 2
|
||||
const nx = -dz / wallLength
|
||||
const nz = dx / wallLength
|
||||
const offset = floorplanWallThickness(node) / 2 + 0.05
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [midX + nx * offset, midZ + nz * offset],
|
||||
angle: Math.atan2(nz, nx),
|
||||
})
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [midX - nx * offset, midZ - nz * offset],
|
||||
angle: Math.atan2(-nz, -nx),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Curve sagitta handle — teal dot at the wall midpoint that
|
||||
// controls `curveOffset`. Hidden when the wall hosts a door /
|
||||
// window / wall-attached item: bending the wall would tear those
|
||||
@@ -191,7 +229,7 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
start: [node.start[0], node.start[1]],
|
||||
end: [node.end[0], node.end[1]],
|
||||
offsetNormal: [nx * facingAway, nz * facingAway],
|
||||
offsetDistance: 0.42,
|
||||
offsetDistance: 0.75,
|
||||
extensionOvershoot: 0.12,
|
||||
text: formatLengthMetric(length),
|
||||
})
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
formatAngleRadians,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
isWallLongEnough,
|
||||
isSegmentLongEnough,
|
||||
type MovingWallEndpoint,
|
||||
markToolCancelConsumed,
|
||||
snapWallDraftPoint,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
WALL_FINE_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
@@ -27,14 +28,12 @@ import { Html } from '@react-three/drei'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — wall endpoint move tool (kind-owned).
|
||||
* Wall endpoint move tool (kind-owned).
|
||||
*
|
||||
* 1:1 port of the legacy `MoveWallEndpointTool` (426 LoC, the largest
|
||||
* single tool in wall/). Same drag pipeline (snap → preview → apply
|
||||
* with linked-wall corner cascade), same Alt-detach modifier, same
|
||||
* angle label between the dragged segment and any neighbour sharing
|
||||
* the endpoint, same single-undo dance on commit, same activation
|
||||
* grace window.
|
||||
* Press-drag-release: the endpoint handle's pointerdown activates this
|
||||
* tool; cursor movement updates the preview (snap → linked-wall cascade
|
||||
* → Alt-detach); pointerup commits if the endpoint actually moved, else
|
||||
* dismisses without committing.
|
||||
*
|
||||
* Mounted via `def.affordanceTools['move-endpoint']` from
|
||||
* `wall/definition.ts`. Editor state trigger is
|
||||
@@ -160,7 +159,7 @@ function getLinkedWallUpdates(
|
||||
}
|
||||
|
||||
export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const hasDraggedRef = useRef(false)
|
||||
const previousGridPosRef = useRef<WallPlanPoint | null>(null)
|
||||
const shiftPressedRef = useRef(false)
|
||||
const altPressedRef = useRef(false)
|
||||
@@ -264,12 +263,21 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
// Endpoint *move* snaps to the grid (and to other wall corners) —
|
||||
// 45° angle snap is for the initial draft, where it gives clean
|
||||
// orthogonal corners; here it would fight every perpendicular
|
||||
// drag by warping the endpoint onto the nearest 45° line from
|
||||
// the fixed corner.
|
||||
//
|
||||
// Shift switches to the *fine* grid step (`WALL_FINE_GRID_STEP`)
|
||||
// for precision placement, so it can land on positions the
|
||||
// active grid would skip (e.g. 0.05m increments when the active
|
||||
// grid is 0.5m). It does NOT bypass snap.
|
||||
const snappedPoint = snapWallDraftPoint({
|
||||
point: planPoint,
|
||||
walls: levelWalls,
|
||||
start: fixedPoint,
|
||||
angleSnap: !shiftPressedRef.current,
|
||||
ignoreWallIds: [nodeId],
|
||||
step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
|
||||
if (
|
||||
@@ -280,13 +288,17 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = snappedPoint
|
||||
hasDraggedRef.current = true
|
||||
|
||||
applyPreview(snappedPoint, event.nativeEvent.altKey)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
const onPointerUp = () => {
|
||||
// Press-release without drag: dismiss the tool without committing.
|
||||
if (!hasDraggedRef.current) {
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
setAngleLabel(null)
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -295,7 +307,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
|
||||
)
|
||||
|
||||
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
||||
if (hasChanged && isSegmentLongEnough(preview.start, preview.end)) {
|
||||
wasCommitted = true
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
@@ -325,7 +337,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
setAngleLabel(null)
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
@@ -367,8 +378,8 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
@@ -379,8 +390,8 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
}
|
||||
resumeSceneHistory(useScene)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getMaterialPresetByRef,
|
||||
resolveMaterial,
|
||||
useScene,
|
||||
type WallMoveBridgePlan,
|
||||
type WallNode,
|
||||
type WallPlanPoint,
|
||||
WallNode as WallSchema,
|
||||
} from '@pascal-app/core'
|
||||
import { isSegmentLongEnough } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Pure helpers shared by the 3D `MoveWallTool` and the 2D
|
||||
* `wallFloorplanMoveTarget`. Lives in `packages/nodes` because the
|
||||
* bridge / ghost helpers depend on `WallSchema.parse` and material
|
||||
* preset resolution; kept React-free so both call sites can import
|
||||
* cleanly.
|
||||
*/
|
||||
|
||||
const POINT_EPSILON = 1e-6
|
||||
|
||||
export function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
|
||||
return Math.abs(a[0] - b[0]) <= POINT_EPSILON && Math.abs(a[1] - b[1]) <= POINT_EPSILON
|
||||
}
|
||||
|
||||
function pointKey(point: WallPlanPoint) {
|
||||
return `${point[0]}:${point[1]}`
|
||||
}
|
||||
|
||||
export function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
||||
return meta
|
||||
}
|
||||
|
||||
const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
|
||||
delete nextMeta.isNew
|
||||
return nextMeta as WallNode['metadata']
|
||||
}
|
||||
|
||||
export type LinkedWallSnapshot = WallNode
|
||||
|
||||
/**
|
||||
* Walls in the same level that share an endpoint with the moving wall,
|
||||
* plus walls one hop further out that share an endpoint with a
|
||||
* directly-linked wall — needed so the junction planner can resolve
|
||||
* pivot-point context when a same-direction wall is consumed.
|
||||
*/
|
||||
export function getLinkedWallSnapshots(args: {
|
||||
wallId: WallNode['id']
|
||||
wallParentId: string | null
|
||||
originalStart: WallPlanPoint
|
||||
originalEnd: WallPlanPoint
|
||||
}): LinkedWallSnapshot[] {
|
||||
const { wallId, wallParentId, originalStart, originalEnd } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const walls = Object.values(nodes).filter(
|
||||
(node): node is WallNode =>
|
||||
node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId,
|
||||
)
|
||||
const directlyLinkedWalls = walls.filter(
|
||||
(wall) =>
|
||||
samePoint(wall.start, originalStart) ||
|
||||
samePoint(wall.start, originalEnd) ||
|
||||
samePoint(wall.end, originalStart) ||
|
||||
samePoint(wall.end, originalEnd),
|
||||
)
|
||||
const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)])
|
||||
|
||||
for (const wall of directlyLinkedWalls) {
|
||||
contextPoints.add(pointKey(wall.start))
|
||||
contextPoints.add(pointKey(wall.end))
|
||||
}
|
||||
|
||||
const snapshots: LinkedWallSnapshot[] = []
|
||||
const seenWallIds = new Set<WallNode['id']>()
|
||||
|
||||
for (const node of walls) {
|
||||
if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (seenWallIds.has(node.id)) {
|
||||
continue
|
||||
}
|
||||
seenWallIds.add(node.id)
|
||||
|
||||
snapshots.push({
|
||||
...node,
|
||||
start: [...node.start] as [number, number],
|
||||
end: [...node.end] as [number, number],
|
||||
children: [...(node.children ?? [])],
|
||||
})
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function wallSegmentExists(
|
||||
walls: Array<Pick<WallNode, 'start' | 'end'>>,
|
||||
start: WallPlanPoint,
|
||||
end: WallPlanPoint,
|
||||
) {
|
||||
return walls.some(
|
||||
(wall) =>
|
||||
(samePoint(wall.start, start) && samePoint(wall.end, end)) ||
|
||||
(samePoint(wall.start, end) && samePoint(wall.end, start)),
|
||||
)
|
||||
}
|
||||
|
||||
export function getWallGhostColor(wall: WallNode) {
|
||||
const presetColor =
|
||||
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
|
||||
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
|
||||
getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color
|
||||
|
||||
if (presetColor) {
|
||||
return presetColor
|
||||
}
|
||||
|
||||
return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color
|
||||
}
|
||||
|
||||
export function getWallsAfterUpdates(
|
||||
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
|
||||
): WallNode[] {
|
||||
const updateById = new Map(updates.map((update) => [update.id, update.data]))
|
||||
|
||||
return Object.values(nodes)
|
||||
.filter((node): node is WallNode => node?.type === 'wall')
|
||||
.map((wall) => {
|
||||
const update = updateById.get(wall.id as AnyNodeId)
|
||||
return update ? ({ ...wall, ...update } as WallNode) : wall
|
||||
})
|
||||
}
|
||||
|
||||
export function buildBridgeWallCreates(args: {
|
||||
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
|
||||
nextStart: WallPlanPoint
|
||||
nextEnd: WallPlanPoint
|
||||
existingWalls: WallNode[]
|
||||
wallCount: number
|
||||
}): Array<{ node: WallNode; parentId?: AnyNodeId }> {
|
||||
const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args
|
||||
const wallsForDuplicateCheck = [...existingWalls]
|
||||
const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = []
|
||||
|
||||
for (const plan of bridgePlans) {
|
||||
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
|
||||
|
||||
if (!isSegmentLongEnough(plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall
|
||||
const bridgeWall = WallSchema.parse({
|
||||
...sourceWall,
|
||||
name: `Wall ${wallCount + creates.length + 1}`,
|
||||
start: plan.originalPoint,
|
||||
end: nextPoint,
|
||||
children: [],
|
||||
metadata: stripWallIsNewMetadata(plan.wall.metadata),
|
||||
})
|
||||
|
||||
creates.push({
|
||||
node: bridgeWall,
|
||||
parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined,
|
||||
})
|
||||
wallsForDuplicateCheck.push(bridgeWall)
|
||||
}
|
||||
|
||||
return creates
|
||||
}
|
||||
|
||||
export type GhostWallPreview = {
|
||||
id: string
|
||||
start: WallPlanPoint
|
||||
end: WallPlanPoint
|
||||
color: string
|
||||
height: number
|
||||
}
|
||||
|
||||
export function buildBridgeWallPreviews(args: {
|
||||
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
|
||||
nextStart: WallPlanPoint
|
||||
nextEnd: WallPlanPoint
|
||||
existingWalls: WallNode[]
|
||||
}): Array<{ ghost: GhostWallPreview; wall: WallNode }> {
|
||||
const { bridgePlans, nextStart, nextEnd, existingWalls } = args
|
||||
const wallsForDuplicateCheck: Array<Pick<WallNode, 'start' | 'end'>> = [...existingWalls]
|
||||
const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = []
|
||||
|
||||
for (const plan of bridgePlans) {
|
||||
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
|
||||
|
||||
if (!isSegmentLongEnough(plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { id: _id, children: _children, ...sourceWall } = plan.wall
|
||||
const wall = WallSchema.parse({
|
||||
...sourceWall,
|
||||
name: 'Wall Preview',
|
||||
start: plan.originalPoint,
|
||||
end: nextPoint,
|
||||
children: [],
|
||||
metadata: stripWallIsNewMetadata(plan.wall.metadata),
|
||||
})
|
||||
const ghost: GhostWallPreview = {
|
||||
id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`,
|
||||
start: [...plan.originalPoint] as WallPlanPoint,
|
||||
end: [...nextPoint] as WallPlanPoint,
|
||||
color: getWallGhostColor(plan.wall),
|
||||
height: plan.wall.height ?? DEFAULT_WALL_HEIGHT,
|
||||
}
|
||||
previews.push({ ghost, wall })
|
||||
wallsForDuplicateCheck.push(wall)
|
||||
}
|
||||
|
||||
return previews
|
||||
}
|
||||
@@ -2,31 +2,31 @@
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
constrainWallMoveDeltaToAxis,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
type AutoCeilingSyncPlan,
|
||||
type AutoSlabSyncPlan,
|
||||
type CeilingNode,
|
||||
detectSpacesForLevel,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getMaterialPresetByRef,
|
||||
getPerpendicularWallMoveAxis,
|
||||
getPlannedLinkedWallUpdates,
|
||||
pauseSceneHistory,
|
||||
planAutoCeilingsForLevel,
|
||||
planAutoSlabsForLevel,
|
||||
planWallMoveJunctions,
|
||||
resolveMaterial,
|
||||
resumeSceneHistory,
|
||||
type SlabNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallMoveAxis,
|
||||
type WallMoveBridgePlan,
|
||||
type WallMoveJunctionPlan,
|
||||
type WallNode,
|
||||
WallNode as WallSchema,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
EDITOR_LAYER,
|
||||
getWallGridStep,
|
||||
isWallLongEnough,
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
markToolCancelConsumed,
|
||||
snapScalarToGrid,
|
||||
triggerSFX,
|
||||
@@ -35,6 +35,15 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
|
||||
import {
|
||||
buildBridgeWallCreates,
|
||||
buildBridgeWallPreviews,
|
||||
type GhostWallPreview,
|
||||
getLinkedWallSnapshots,
|
||||
getWallsAfterUpdates,
|
||||
type LinkedWallSnapshot,
|
||||
stripWallIsNewMetadata,
|
||||
} from './move-shared'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — wall whole-move tool (kind-owned).
|
||||
@@ -65,302 +74,17 @@ function rotateVector([x, z]: [number, number], angle: number): [number, number]
|
||||
return [x * cos - z * sin, x * sin + z * cos]
|
||||
}
|
||||
|
||||
function samePoint(a: [number, number], b: [number, number]) {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
function pointKey(point: [number, number]) {
|
||||
return `${point[0]}:${point[1]}`
|
||||
}
|
||||
|
||||
function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
||||
return meta
|
||||
}
|
||||
|
||||
const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
|
||||
delete nextMeta.isNew
|
||||
return nextMeta as WallNode['metadata']
|
||||
}
|
||||
|
||||
type LinkedWallSnapshot = WallNode
|
||||
|
||||
type GhostWallPreview = {
|
||||
id: string
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
color: string
|
||||
height: number
|
||||
}
|
||||
|
||||
function getLinkedWallSnapshots(args: {
|
||||
wallId: WallNode['id']
|
||||
wallParentId: string | null
|
||||
originalStart: [number, number]
|
||||
originalEnd: [number, number]
|
||||
}) {
|
||||
const { wallId, wallParentId, originalStart, originalEnd } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const walls = Object.values(nodes).filter(
|
||||
(node): node is WallNode =>
|
||||
node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId,
|
||||
)
|
||||
const directlyLinkedWalls = walls.filter(
|
||||
(wall) =>
|
||||
samePoint(wall.start, originalStart) ||
|
||||
samePoint(wall.start, originalEnd) ||
|
||||
samePoint(wall.end, originalStart) ||
|
||||
samePoint(wall.end, originalEnd),
|
||||
)
|
||||
const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)])
|
||||
|
||||
for (const wall of directlyLinkedWalls) {
|
||||
contextPoints.add(pointKey(wall.start))
|
||||
contextPoints.add(pointKey(wall.end))
|
||||
}
|
||||
|
||||
const snapshots: LinkedWallSnapshot[] = []
|
||||
const seenWallIds = new Set<WallNode['id']>()
|
||||
|
||||
for (const node of walls) {
|
||||
if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (seenWallIds.has(node.id)) {
|
||||
continue
|
||||
}
|
||||
seenWallIds.add(node.id)
|
||||
|
||||
snapshots.push({
|
||||
...node,
|
||||
start: [...node.start] as [number, number],
|
||||
end: [...node.end] as [number, number],
|
||||
children: [...(node.children ?? [])],
|
||||
})
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function getLinkedWallUpdates(
|
||||
linkedWalls: Array<{
|
||||
wall: LinkedWallSnapshot
|
||||
matchPoint?: [number, number]
|
||||
targetPoint?: [number, number]
|
||||
}>,
|
||||
originalStart: [number, number],
|
||||
originalEnd: [number, number],
|
||||
nextStart: [number, number],
|
||||
nextEnd: [number, number],
|
||||
) {
|
||||
return linkedWalls.map(({ wall, matchPoint, targetPoint }) => {
|
||||
if (matchPoint && targetPoint) {
|
||||
return {
|
||||
id: wall.id,
|
||||
start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start,
|
||||
end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end,
|
||||
}
|
||||
}
|
||||
|
||||
const targetStart = targetPoint ?? nextStart
|
||||
const targetEnd = targetPoint ?? nextEnd
|
||||
|
||||
return {
|
||||
id: wall.id,
|
||||
start: samePoint(wall.start, originalStart)
|
||||
? targetStart
|
||||
: samePoint(wall.start, originalEnd)
|
||||
? targetEnd
|
||||
: wall.start,
|
||||
end: samePoint(wall.end, originalStart)
|
||||
? targetStart
|
||||
: samePoint(wall.end, originalEnd)
|
||||
? targetEnd
|
||||
: wall.end,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getPlannedLinkedWallUpdates(
|
||||
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
|
||||
originalStart: [number, number],
|
||||
originalEnd: [number, number],
|
||||
nextStart: [number, number],
|
||||
nextEnd: [number, number],
|
||||
) {
|
||||
const movePlans = new Map<
|
||||
WallNode['id'],
|
||||
{ wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] }
|
||||
>()
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
function wallSegmentExists(
|
||||
walls: Array<Pick<WallNode, 'start' | 'end'>>,
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
) {
|
||||
return walls.some(
|
||||
(wall) =>
|
||||
(samePoint(wall.start, start) && samePoint(wall.end, end)) ||
|
||||
(samePoint(wall.start, end) && samePoint(wall.end, start)),
|
||||
)
|
||||
}
|
||||
|
||||
function getWallGhostColor(wall: WallNode) {
|
||||
const presetColor =
|
||||
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
|
||||
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
|
||||
getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color
|
||||
|
||||
if (presetColor) {
|
||||
return presetColor
|
||||
}
|
||||
|
||||
return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color
|
||||
}
|
||||
|
||||
function getWallsAfterUpdates(
|
||||
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
|
||||
) {
|
||||
const updateById = new Map(updates.map((update) => [update.id, update.data]))
|
||||
|
||||
return Object.values(nodes)
|
||||
.filter((node): node is WallNode => node?.type === 'wall')
|
||||
.map((wall) => {
|
||||
const update = updateById.get(wall.id as AnyNodeId)
|
||||
return update ? ({ ...wall, ...update } as WallNode) : wall
|
||||
})
|
||||
}
|
||||
|
||||
function cloneSlabSnapshot(slab: SlabNode): SlabNode {
|
||||
return {
|
||||
...slab,
|
||||
polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]),
|
||||
holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
||||
holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })),
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
||||
return Object.values(nodes).filter(
|
||||
(entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId,
|
||||
)
|
||||
}
|
||||
|
||||
function getLevelAutoSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
||||
return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls)
|
||||
}
|
||||
|
||||
function getLevelAutoSlabSnapshots(levelId: string) {
|
||||
return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot)
|
||||
}
|
||||
|
||||
function buildBridgeWallCreates(args: {
|
||||
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
|
||||
nextStart: [number, number]
|
||||
nextEnd: [number, number]
|
||||
existingWalls: WallNode[]
|
||||
wallCount: number
|
||||
}): Array<{ node: WallNode; parentId?: AnyNodeId }> {
|
||||
const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args
|
||||
const wallsForDuplicateCheck = [...existingWalls]
|
||||
const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = []
|
||||
|
||||
for (const plan of bridgePlans) {
|
||||
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
|
||||
|
||||
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall
|
||||
const bridgeWall = WallSchema.parse({
|
||||
...sourceWall,
|
||||
name: `Wall ${wallCount + creates.length + 1}`,
|
||||
start: plan.originalPoint,
|
||||
end: nextPoint,
|
||||
children: [],
|
||||
metadata: stripWallIsNewMetadata(plan.wall.metadata),
|
||||
})
|
||||
|
||||
creates.push({
|
||||
node: bridgeWall,
|
||||
parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined,
|
||||
})
|
||||
wallsForDuplicateCheck.push(bridgeWall)
|
||||
}
|
||||
|
||||
return creates
|
||||
}
|
||||
|
||||
function buildBridgeWallPreviews(args: {
|
||||
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
|
||||
nextStart: [number, number]
|
||||
nextEnd: [number, number]
|
||||
existingWalls: WallNode[]
|
||||
}): Array<{ ghost: GhostWallPreview; wall: WallNode }> {
|
||||
const { bridgePlans, nextStart, nextEnd, existingWalls } = args
|
||||
const wallsForDuplicateCheck: Array<Pick<WallNode, 'start' | 'end'>> = [...existingWalls]
|
||||
const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = []
|
||||
|
||||
for (const plan of bridgePlans) {
|
||||
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
|
||||
|
||||
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { id: _id, children: _children, ...sourceWall } = plan.wall
|
||||
const wall = WallSchema.parse({
|
||||
...sourceWall,
|
||||
name: 'Wall Preview',
|
||||
start: plan.originalPoint,
|
||||
end: nextPoint,
|
||||
children: [],
|
||||
metadata: stripWallIsNewMetadata(plan.wall.metadata),
|
||||
})
|
||||
const ghost = {
|
||||
id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`,
|
||||
start: [...plan.originalPoint] as [number, number],
|
||||
end: [...nextPoint] as [number, number],
|
||||
color: getWallGhostColor(plan.wall),
|
||||
height: plan.wall.height ?? DEFAULT_WALL_HEIGHT,
|
||||
}
|
||||
previews.push({ ghost, wall })
|
||||
wallsForDuplicateCheck.push(wall)
|
||||
}
|
||||
|
||||
return previews
|
||||
function getLevelCeilings(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
||||
return Object.values(nodes).filter(
|
||||
(entry): entry is CeilingNode =>
|
||||
entry?.type === 'ceiling' && (entry.parentId ?? null) === levelId,
|
||||
)
|
||||
}
|
||||
|
||||
function setPreviewGeometryAttributes(
|
||||
@@ -405,8 +129,17 @@ function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) {
|
||||
|
||||
return (
|
||||
<group position={[preview.start[0], 0.02, preview.start[1]]} rotation={[0, angle, 0]}>
|
||||
<mesh frustumCulled={false} layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<primitive attach="geometry" object={geometry} />
|
||||
<mesh
|
||||
// Pass geometry as a prop so the mesh never renders with R3F's
|
||||
// default empty `BufferGeometry`. With `frustumCulled={false}`,
|
||||
// the `<primitive attach="geometry">` path emits one frame of
|
||||
// `Draw(0, 1, 0, 0)` against an empty buffer and WebGPU flags it
|
||||
// (see wall-move-side-handles.tsx).
|
||||
frustumCulled={false}
|
||||
geometry={geometry}
|
||||
layers={EDITOR_LAYER}
|
||||
renderOrder={2}
|
||||
>
|
||||
<meshBasicMaterial
|
||||
color={preview.color}
|
||||
depthTest={false}
|
||||
@@ -426,7 +159,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const hasDraggedRef = useRef(false)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
||||
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
||||
@@ -451,9 +184,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
originalEnd: node.end,
|
||||
}),
|
||||
)
|
||||
const originalAutoSlabsRef = useRef<SlabNode[]>(
|
||||
node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [],
|
||||
)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const nodeIdRef = useRef(node.id)
|
||||
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
|
||||
@@ -478,8 +208,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const originalCenter = originalCenterRef.current
|
||||
const originalHalfVector = originalHalfVectorRef.current
|
||||
const levelId = node.parentId ?? null
|
||||
const originalAutoSlabs = originalAutoSlabsRef.current
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let shouldRestoreOnCleanup = true
|
||||
|
||||
@@ -497,72 +225,127 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const applyLiveAutoSlabPreview = (walls: WallNode[]) => {
|
||||
if (!levelId) {
|
||||
return
|
||||
}
|
||||
// Live auto-slab / auto-ceiling preview. Calculates the new
|
||||
// surfaces from the moving wall configuration each tick, then
|
||||
// sends polygon overrides to `useLiveNodeOverrides` (and marks
|
||||
// the slab / ceiling dirty) so `GeometrySystem` / `CeilingSystem`
|
||||
// rebuild the mesh through the existing `getEffectiveNode` merge
|
||||
// path. *Nothing* is written to the scene store during the drag —
|
||||
// the store stays at pre-drag values; commit writes the final
|
||||
// plan in one atomic `applyNodeChanges`. Mirrors the wall's own
|
||||
// 2D drag pattern (`useLiveNodeOverrides` → mesh, store on
|
||||
// commit).
|
||||
//
|
||||
// Creates / deletes from the plan are *deferred* to commit. We
|
||||
// can't represent a "node that doesn't exist yet" through
|
||||
// overrides, so newly-detected rooms only get their slab/ceiling
|
||||
// materialised on release; rooms whose closing wall pulls away
|
||||
// keep their slab/ceiling visible (at the original polygon) until
|
||||
// commit clears them. UX-wise this trades the previous "ghost
|
||||
// appears/disappears mid-drag" feedback for atomic, undoable
|
||||
// commits — which is what the user asked for.
|
||||
//
|
||||
// `latestSurfacePlans` holds the most recent plan from the last
|
||||
// live tick. `commitSurfacesToStore` flushes it (creates,
|
||||
// updates, deletes) into the scene as part of the commit-time
|
||||
// atomic write, then `clearSurfaceOverrides` drops the override
|
||||
// map so the system reads from the now-committed store.
|
||||
let latestSurfacePlans: { slabs: AutoSlabSyncPlan; ceilings: AutoCeilingSyncPlan } | null = null
|
||||
const touchedSlabIds = new Set<AnyNodeId>()
|
||||
const touchedCeilingIds = new Set<AnyNodeId>()
|
||||
|
||||
const publishLiveSurfaceOverrides = (walls: WallNode[]) => {
|
||||
if (!levelId) return
|
||||
|
||||
const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId)
|
||||
const sceneState = useScene.getState()
|
||||
const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls)
|
||||
const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes))
|
||||
|
||||
if (
|
||||
slabPlan.create.length === 0 &&
|
||||
slabPlan.update.length === 0 &&
|
||||
slabPlan.delete.length === 0
|
||||
) {
|
||||
return
|
||||
// Plan against the pre-drag scene — `getLevelSlabs/Ceilings`
|
||||
// reads from the unchanged store, so the matcher's "remap
|
||||
// existing room → existing slab" logic sees stable IDs across
|
||||
// ticks. Without this anchor, IDs would drift as overrides
|
||||
// re-flowed through the planner.
|
||||
const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes))
|
||||
const ceilingPlan = planAutoCeilingsForLevel(
|
||||
roomPolygons,
|
||||
getLevelCeilings(levelId, sceneState.nodes),
|
||||
)
|
||||
|
||||
latestSurfacePlans = { slabs: slabPlan, ceilings: ceilingPlan }
|
||||
|
||||
const overrideEntries: Array<[string, Record<string, unknown>]> = []
|
||||
for (const update of slabPlan.update) {
|
||||
if (update.data.polygon === undefined) continue
|
||||
overrideEntries.push([update.id, { polygon: update.data.polygon }])
|
||||
touchedSlabIds.add(update.id as AnyNodeId)
|
||||
}
|
||||
for (const update of ceilingPlan.update) {
|
||||
if (update.data.polygon === undefined) continue
|
||||
overrideEntries.push([update.id, { polygon: update.data.polygon }])
|
||||
touchedCeilingIds.add(update.id as AnyNodeId)
|
||||
}
|
||||
|
||||
sceneState.applyNodeChanges({
|
||||
update: slabPlan.update.map((entry) => ({
|
||||
if (overrideEntries.length > 0) {
|
||||
useLiveNodeOverrides.getState().setMany(overrideEntries)
|
||||
for (const [id] of overrideEntries) {
|
||||
sceneState.markDirty(id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit-time: flush the last live plan into the scene store as
|
||||
// one atomic write, then drop overrides so the renderer reads
|
||||
// from the now-current store state. Called from `commitPreview`
|
||||
// *while history is resumed*, so the entire surface delta is one
|
||||
// undoable step alongside the wall update.
|
||||
const commitSurfacesToStore = () => {
|
||||
if (!levelId || !latestSurfacePlans) return
|
||||
|
||||
const { slabs, ceilings } = latestSurfacePlans
|
||||
const update = [
|
||||
...slabs.update.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: entry.data,
|
||||
})),
|
||||
create: slabPlan.create.map((slab) => ({
|
||||
...ceilings.update.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: entry.data,
|
||||
})),
|
||||
]
|
||||
const create = [
|
||||
...slabs.create.map((slab) => ({
|
||||
node: slab,
|
||||
parentId: levelId as AnyNodeId,
|
||||
})),
|
||||
delete: slabPlan.delete.map((id) => id as AnyNodeId),
|
||||
})
|
||||
}
|
||||
|
||||
const restoreAutoSlabPreview = () => {
|
||||
if (!levelId) {
|
||||
return
|
||||
}
|
||||
|
||||
const sceneState = useScene.getState()
|
||||
const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id))
|
||||
const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes)
|
||||
const update = originalAutoSlabs
|
||||
.filter((slab) => sceneState.nodes[slab.id as AnyNodeId])
|
||||
.map((slab) => ({
|
||||
id: slab.id as AnyNodeId,
|
||||
data: cloneSlabSnapshot(slab),
|
||||
}))
|
||||
const create = originalAutoSlabs
|
||||
.filter((slab) => !sceneState.nodes[slab.id as AnyNodeId])
|
||||
.map((slab) => ({
|
||||
node: cloneSlabSnapshot(slab),
|
||||
...ceilings.create.map((ceiling) => ({
|
||||
node: ceiling,
|
||||
parentId: levelId as AnyNodeId,
|
||||
}))
|
||||
const deleteIds = currentAutoSlabs
|
||||
.filter((slab) => !originalIds.has(slab.id))
|
||||
.map((slab) => slab.id as AnyNodeId)
|
||||
})),
|
||||
]
|
||||
const deleteIds = [
|
||||
...slabs.delete.map((id) => id as AnyNodeId),
|
||||
...ceilings.delete.map((id) => id as AnyNodeId),
|
||||
]
|
||||
|
||||
if (update.length === 0 && create.length === 0 && deleteIds.length === 0) {
|
||||
return
|
||||
}
|
||||
if (update.length === 0 && create.length === 0 && deleteIds.length === 0) return
|
||||
|
||||
sceneState.applyNodeChanges({
|
||||
useScene.getState().applyNodeChanges({
|
||||
update,
|
||||
create,
|
||||
delete: deleteIds,
|
||||
})
|
||||
}
|
||||
|
||||
const clearSurfaceOverrides = () => {
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
for (const id of touchedSlabIds) overrides.clear(id)
|
||||
for (const id of touchedCeilingIds) overrides.clear(id)
|
||||
touchedSlabIds.clear()
|
||||
touchedCeilingIds.clear()
|
||||
latestSurfacePlans = null
|
||||
}
|
||||
|
||||
const buildWallFromCenter = (center: [number, number]) => {
|
||||
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
|
||||
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
|
||||
@@ -610,7 +393,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
]
|
||||
const previewCollapsedWallIds = new Set([
|
||||
...previewUpdates
|
||||
.filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end))
|
||||
.filter((entry) => entry.id !== nodeId && !isSegmentLongEnough(entry.start, entry.end))
|
||||
.map((entry) => entry.id as AnyNodeId),
|
||||
...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
|
||||
])
|
||||
@@ -631,7 +414,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall)
|
||||
setGhostWallPreviews(nextGhostWalls)
|
||||
applyNodePreview(previewUpdates)
|
||||
applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls])
|
||||
publishLiveSurfaceOverrides([...previewSceneWalls, ...virtualBridgeWalls])
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
@@ -640,24 +423,54 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
restoreAutoSlabPreview()
|
||||
// No scene rollback for surfaces — nothing was written. Just
|
||||
// clear the live overrides so the renderer falls back to the
|
||||
// (pre-drag, unchanged) store state.
|
||||
clearSurfaceOverrides()
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
const snapStep = getWallGridStep()
|
||||
const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
|
||||
const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep)
|
||||
const snapStep = getSegmentGridStep()
|
||||
|
||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||
// Anchor at the raw cursor so the displacement is measured in
|
||||
// continuous space.
|
||||
const anchor = dragAnchorRef.current ?? [rawX, rawZ]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
|
||||
localX - anchor[0],
|
||||
localZ - anchor[1],
|
||||
moveAxisRef.current,
|
||||
)
|
||||
const rawDeltaX = rawX - anchor[0]
|
||||
const rawDeltaZ = rawZ - anchor[1]
|
||||
|
||||
// When the move is axis-locked (side-handle drag), snap the wall
|
||||
// center's absolute perpendicular offset to a multiple of
|
||||
// `snapStep`. For axis-aligned walls this puts the wall on grid
|
||||
// lines along its normal regardless of the wall's starting
|
||||
// position; for diagonal walls the wall steps in exact
|
||||
// `snapStep` increments along its normal.
|
||||
//
|
||||
// Why absolute (perp · centre) rather than `delta` snap: snapping
|
||||
// the displacement alone preserves any pre-existing off-grid
|
||||
// offset, so the wall could only ever sit at `originalCentre ±
|
||||
// k·snapStep` — never on actual grid lines. Snapping the wall's
|
||||
// own perpendicular coordinate fixes that.
|
||||
const axis = moveAxisRef.current
|
||||
let deltaX: number
|
||||
let deltaZ: number
|
||||
if (axis) {
|
||||
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1]
|
||||
const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1]
|
||||
const snappedProj = shiftPressedRef.current
|
||||
? rawProj
|
||||
: snapScalarToGrid(rawProj, snapStep)
|
||||
const perpDelta = snappedProj - originalProj
|
||||
deltaX = axis[0] * perpDelta
|
||||
deltaZ = axis[1] * perpDelta
|
||||
} else {
|
||||
deltaX = shiftPressedRef.current ? rawDeltaX : snapScalarToGrid(rawDeltaX, snapStep)
|
||||
deltaZ = shiftPressedRef.current ? rawDeltaZ : snapScalarToGrid(rawDeltaZ, snapStep)
|
||||
}
|
||||
|
||||
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
|
||||
|
||||
if (
|
||||
@@ -671,27 +484,24 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
|
||||
const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
|
||||
const nextWall = buildWallFromCenter(nextCenter)
|
||||
hasDraggedRef.current = true
|
||||
applyPreview(nextWall.start, nextWall.end)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const commitPreview = () => {
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
|
||||
shouldRestoreOnCleanup = false
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
// Surfaces stayed in the store the whole drag (override-driven
|
||||
// mesh preview), so there's nothing to restore for them.
|
||||
setGhostWallPreviews([])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
restoreAutoSlabPreview()
|
||||
|
||||
resumeSceneHistory(useScene)
|
||||
const commitPlan = getMovePlan(preview.start, preview.end)
|
||||
@@ -704,7 +514,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
)
|
||||
const collapsedLinkedWallIds = new Set([
|
||||
...linkedWallUpdates
|
||||
.filter((entry) => !isWallLongEnough(entry.start, entry.end))
|
||||
.filter((entry) => !isSegmentLongEnough(entry.start, entry.end))
|
||||
.map((entry) => entry.id as AnyNodeId),
|
||||
...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
|
||||
])
|
||||
@@ -744,12 +554,37 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
delete: Array.from(collapsedLinkedWallIds),
|
||||
})
|
||||
|
||||
// Flush the last live surface plan (slab + ceiling creates,
|
||||
// updates, deletes) into the store while history is still
|
||||
// resumed, so the surface delta joins the wall change as one
|
||||
// undoable step. Then drop the live overrides — the renderer
|
||||
// now reads the committed polygons directly.
|
||||
commitSurfacesToStore()
|
||||
clearSurfaceOverrides()
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
|
||||
// Claim teardown ownership so the 2D overlay's cleanup skips its
|
||||
// own revert when split-view has both mounted — see
|
||||
// `movingNodeOrigin` in `use-editor.tsx`.
|
||||
useEditor.getState().setMovingNodeOrigin('3d')
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
// Press-release without drag: dismiss the tool without committing.
|
||||
// This is the same UX as MoveWallEndpointTool / WallHeightArrowHandle
|
||||
// — pointer-down on the affordance starts the move, drag updates the
|
||||
// preview, release commits if the cursor actually moved.
|
||||
if (!hasDraggedRef.current) {
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
commitPreview()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -797,24 +632,36 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
markToolCancelConsumed()
|
||||
// Claim teardown ownership so the 2D overlay doesn't redundantly
|
||||
// revert the same baseline on its own cleanup.
|
||||
useEditor.getState().setMovingNodeOrigin('3d')
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
|
||||
return () => {
|
||||
if (shouldRestoreOnCleanup) {
|
||||
restoreOriginal()
|
||||
// `shouldRestoreOnCleanup` is only true if neither `onPointerUp`
|
||||
// (commit branch) nor `onCancel` ran — i.e., the unmount came
|
||||
// from outside (typically the 2D overlay finalising in split
|
||||
// view). The origin flag tells us whether the 2D side committed
|
||||
// (skip restore — its write is the live state) or the unmount
|
||||
// has no claimed owner (restore to baseline).
|
||||
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
||||
if (!finalisedBy2D) {
|
||||
restoreOriginal()
|
||||
}
|
||||
}
|
||||
shiftPressedRef.current = false
|
||||
resumeSceneHistory(useScene)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getMaxWallCurveOffset,
|
||||
getWallCurveLength,
|
||||
normalizeWallCurveOffset,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -20,19 +21,32 @@ import {
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Spline } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { Spline } from 'lucide-react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
|
||||
export default function WallPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
|
||||
const node = useScene((s) =>
|
||||
const sceneNode = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
// Live override published by the 2D drag handlers (side-arrows /
|
||||
// corner dots / curve handle). Merged on top of the scene node so
|
||||
// the sliders read the live `start` / `end` / `curveOffset` during
|
||||
// a drag without zustand being touched until commit.
|
||||
const liveOverride = useLiveNodeOverrides((s) =>
|
||||
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
|
||||
)
|
||||
|
||||
const node = useMemo<WallNode | undefined>(() => {
|
||||
if (!sceneNode) return undefined
|
||||
if (!liveOverride || Object.keys(liveOverride).length === 0) return sceneNode
|
||||
return { ...sceneNode, ...liveOverride } as WallNode
|
||||
}, [sceneNode, liveOverride])
|
||||
|
||||
// Boolean selector — re-renders only when this specific wall's child
|
||||
// composition crosses the "has a door/window/wall-item" threshold.
|
||||
const hasWallChildrenBlockingCurve = useScene((s) => {
|
||||
@@ -94,13 +108,6 @@ export default function WallPanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
triggerSFX('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleCurve = useCallback(() => {
|
||||
if (!node) return
|
||||
triggerSFX('sfx:item-pick')
|
||||
@@ -171,18 +178,17 @@ export default function WallPanel() {
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
{!hasWallChildrenBlockingCurve && (
|
||||
{!hasWallChildrenBlockingCurve && (
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton
|
||||
icon={<Spline className="h-3.5 w-3.5" />}
|
||||
label="Curve"
|
||||
onClick={handleCurve}
|
||||
/>
|
||||
)}
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
)}
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type SegmentAngleReference,
|
||||
snapWallDraftPoint,
|
||||
triggerSFX,
|
||||
WALL_FINE_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
@@ -407,14 +408,19 @@ export const WallTool: React.FC = () => {
|
||||
|
||||
const walls = getCurrentLevelWalls()
|
||||
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
gridPosition = snapWallDraftPoint({ point: localPoint, walls })
|
||||
// Default to the active grid step; Shift switches to the fine
|
||||
// step (0.05m) for precision. No 45° angle snap — we want the
|
||||
// cursor to track grid lines in every direction. Orthogonal
|
||||
// walls fall out of grid snap naturally when the start sits on
|
||||
// a grid intersection.
|
||||
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
gridPosition = snapWallDraftPoint({ point: localPoint, walls, step })
|
||||
|
||||
if (buildingState.current === 1) {
|
||||
const snappedLocal = snapWallDraftPoint({
|
||||
point: localPoint,
|
||||
walls,
|
||||
start: [startingPoint.current.x, startingPoint.current.z],
|
||||
angleSnap: !shiftPressed.current,
|
||||
step,
|
||||
})
|
||||
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
||||
cursorRef.current.position.copy(endingPoint.current)
|
||||
@@ -453,20 +459,27 @@ export const WallTool: React.FC = () => {
|
||||
const walls = getCurrentLevelWalls()
|
||||
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
|
||||
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = snapWallDraftPoint({ point: localClick, walls })
|
||||
const snappedStart = snapWallDraftPoint({ point: localClick, walls, step: clickStep })
|
||||
gridPosition = snappedStart
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
wallPreviewRef.current.visible = true
|
||||
// Visibility is owned by `updateWallPreview` — it flips
|
||||
// `mesh.visible` based on segment length. Setting it here
|
||||
// (before any geometry data has been written) draws the
|
||||
// mesh's empty `<shapeGeometry/>` placeholder, which WebGPU
|
||||
// flags as "Vertex buffer slot 0 ... was not set" on the
|
||||
// first frame after click. Leaving it false until the next
|
||||
// `onGridMove` writes a real BoxGeometry skips that frame.
|
||||
setDraftMeasurement(null)
|
||||
} else if (buildingState.current === 1) {
|
||||
const snappedEnd = snapWallDraftPoint({
|
||||
point: localClick,
|
||||
walls,
|
||||
start: [startingPoint.current.x, startingPoint.current.z],
|
||||
angleSnap: !shiftPressed.current,
|
||||
step: clickStep,
|
||||
})
|
||||
const dx = snappedEnd[0] - startingPoint.current.x
|
||||
const dz = snappedEnd[1] - startingPoint.current.z
|
||||
@@ -483,6 +496,12 @@ export const WallTool: React.FC = () => {
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
cursorRef.current?.position.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
// Hide the preview until the next `onGridMove` writes the
|
||||
// new segment's geometry. Without this the prior segment's
|
||||
// BoxGeometry stays visible for a frame on top of the
|
||||
// freshly-committed real wall, producing a brief
|
||||
// double-paint at the new wall's position.
|
||||
wallPreviewRef.current.visible = false
|
||||
setDraftMeasurement(null)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user