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
@@ -27,6 +27,7 @@ export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
|
||||
// selection, never 3D click. Same reasoning as `level` / `site`.
|
||||
duplicable: false,
|
||||
deletable: false,
|
||||
floorplanLevelContainer: true,
|
||||
},
|
||||
|
||||
parametrics: buildingParametrics,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type CeilingNode,
|
||||
resolveLevelId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { PolygonEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — ceiling boundary editor (registry-driven).
|
||||
@@ -11,12 +16,17 @@ import { useCallback } from 'react'
|
||||
* Thin wrapper around the shared `<PolygonEditor>` (same shape as
|
||||
* slab's boundary-editor). Activates when a ceiling is selected in
|
||||
* structure/select mode and no hole edit is in progress.
|
||||
*
|
||||
* Drag flow mirrors slab: `onPolygonPreview` pushes the in-flight
|
||||
* polygon to `useLiveNodeOverrides` so the ceiling mesh rebuilds at
|
||||
* pointer rate; `onPolygonChange` is the single commit on release.
|
||||
*/
|
||||
export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = ({
|
||||
ceilingId,
|
||||
}) => {
|
||||
const ceilingNode = useScene((s) => s.nodes[ceilingId])
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const markDirty = useScene((s) => s.markDirty)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
|
||||
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
|
||||
@@ -29,6 +39,27 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
||||
[ceilingId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
const handlePolygonPreview = useCallback(
|
||||
(preview: ReadonlyArray<readonly [number, number]> | null) => {
|
||||
if (preview) {
|
||||
useLiveNodeOverrides.getState().set(ceilingId, {
|
||||
polygon: preview.map(([x, z]) => [x, z] as [number, number]),
|
||||
})
|
||||
} else {
|
||||
useLiveNodeOverrides.getState().clear(ceilingId)
|
||||
}
|
||||
markDirty(ceilingId)
|
||||
},
|
||||
[ceilingId, markDirty],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useLiveNodeOverrides.getState().clear(ceilingId)
|
||||
useScene.getState().markDirty(ceilingId)
|
||||
}
|
||||
}, [ceilingId])
|
||||
|
||||
if (!ceiling?.polygon || ceiling.polygon.length < 3) return null
|
||||
|
||||
return (
|
||||
@@ -38,6 +69,7 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
onPolygonPreview={handlePolygonPreview}
|
||||
polygon={ceiling.polygon}
|
||||
surfaceHeight={ceiling.height ?? 2.5}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import type {
|
||||
CeilingNode as CeilingNodeType,
|
||||
HandleDescriptor,
|
||||
NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { buildCeilingFloorplan } from './floorplan'
|
||||
import {
|
||||
ceilingAddVertexAffordance,
|
||||
@@ -9,6 +13,52 @@ import { ceilingFloorplanMoveTarget } from './floorplan-move'
|
||||
import { ceilingParametrics } from './parametrics'
|
||||
import { CeilingNode } from './schema'
|
||||
|
||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||
const MIN_CEILING_HEIGHT = 0.5
|
||||
|
||||
function ceilingPolygonCenter(n: CeilingNodeType): [number, number] {
|
||||
const polygon = n.polygon ?? []
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
let cx = 0
|
||||
let cz = 0
|
||||
for (const [x, z] of polygon) {
|
||||
cx += x
|
||||
cz += z
|
||||
}
|
||||
return [cx / polygon.length, cz / polygon.length]
|
||||
}
|
||||
|
||||
// Ceiling height arrow — vertical chevron at the polygon centroid,
|
||||
// hovering just above the ceiling plane. Drags the `height` field
|
||||
// (the Y position of the ceiling surface). `anchor: 'min'` so dragging
|
||||
// the cursor upward grows the value directly. Live override + commit
|
||||
// flow comes from the shared registry arrow pipeline.
|
||||
//
|
||||
// The placement Y is in *mesh-local* coords. CeilingSystem already
|
||||
// parks `mesh.position.y = ceiling.height - 0.01`, so the local Y is
|
||||
// just the offset above that plane (NOT `height + offset` — that
|
||||
// would double-add the height and push the arrow off-screen).
|
||||
function ceilingHeightHandle(): HandleDescriptor<CeilingNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_CEILING_HEIGHT,
|
||||
currentValue: (n) => n.height ?? 2.5,
|
||||
apply: (_n, newValue) => ({ height: newValue }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const [cx, cz] = ceilingPolygonCenter(n)
|
||||
return [cx, HEIGHT_HANDLE_OFFSET, cz]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ceilingHandles(_node: CeilingNodeType): HandleDescriptor<CeilingNodeType>[] {
|
||||
return [ceilingHeightHandle()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceiling — Phase 5 batch kind, polygon-based. Structurally similar to
|
||||
* slab but with React-rendered hosted children + TSL shader materials +
|
||||
@@ -60,6 +110,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
},
|
||||
|
||||
parametrics: ceilingParametrics,
|
||||
handles: ceilingHandles,
|
||||
|
||||
// Stage D: kind-owned placement tool. Multi-click polygon drawing
|
||||
// with a vertical TSL-gradient connector + ground-shadow lines.
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type CeilingNode,
|
||||
resolveLevelId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { PolygonEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — ceiling hole editor (registry-driven).
|
||||
@@ -14,6 +19,7 @@ export const CeilingHoleEditor: React.FC<{
|
||||
}> = ({ ceilingId, holeIndex }) => {
|
||||
const ceilingNode = useScene((s) => s.nodes[ceilingId])
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const markDirty = useScene((s) => s.markDirty)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
|
||||
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
|
||||
@@ -30,6 +36,27 @@ export const CeilingHoleEditor: React.FC<{
|
||||
[ceilingId, holeIndex, holes, updateNode, setSelection],
|
||||
)
|
||||
|
||||
const handlePolygonPreview = useCallback(
|
||||
(preview: ReadonlyArray<readonly [number, number]> | null) => {
|
||||
if (preview) {
|
||||
const updatedHoles = [...holes]
|
||||
updatedHoles[holeIndex] = preview.map(([x, z]) => [x, z] as [number, number])
|
||||
useLiveNodeOverrides.getState().set(ceilingId, { holes: updatedHoles })
|
||||
} else {
|
||||
useLiveNodeOverrides.getState().clear(ceilingId)
|
||||
}
|
||||
markDirty(ceilingId)
|
||||
},
|
||||
[ceilingId, holeIndex, holes, markDirty],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useLiveNodeOverrides.getState().clear(ceilingId)
|
||||
useScene.getState().markDirty(ceilingId)
|
||||
}
|
||||
}, [ceilingId])
|
||||
|
||||
if (!(ceiling && hole) || hole.length < 3) return null
|
||||
|
||||
return (
|
||||
@@ -40,6 +67,7 @@ export const CeilingHoleEditor: React.FC<{
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
onPolygonPreview={handlePolygonPreview}
|
||||
polygon={hole}
|
||||
surfaceHeight={ceiling.height ?? 2.5}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,284 @@
|
||||
import {
|
||||
ColumnNode as ColumnNodeSchema,
|
||||
type ColumnNode as ColumnNodeType,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { buildColumnFloorplan } from './floorplan'
|
||||
import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances'
|
||||
import { columnParametrics } from './parametrics'
|
||||
import { ColumnNode } from './schema'
|
||||
|
||||
// Limits + offsets shared with the in-world arrows. Mirrors the floors
|
||||
// the renderer clamps to (`Math.max(0.2, node.height)` etc.) so a drag
|
||||
// can't push values past what the renderer will accept.
|
||||
const SIDE_HANDLE_OFFSET = 0.18
|
||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||
const BRACE_HANDLE_OFFSET = 0.3
|
||||
const SPREAD_HANDLE_OFFSET = 0.22
|
||||
const ROTATE_CORNER_OFFSET = 0.32
|
||||
const ROTATE_RING_OFFSET = 0.04
|
||||
const MIN_COLUMN_HEIGHT = 0.2
|
||||
const MIN_COLUMN_WIDTH = 0.1
|
||||
const MIN_COLUMN_DEPTH = 0.1
|
||||
const MIN_COLUMN_RADIUS = 0.05
|
||||
const MIN_BRACE_DIMENSION = 0.04
|
||||
const MIN_BRACE_BOTTOM_SPREAD = 0.2
|
||||
const MIN_BRACE_TOP_SPREAD = 0
|
||||
|
||||
const ROUND_CROSS_SECTIONS = new Set<ColumnNodeType['crossSection']>([
|
||||
'round',
|
||||
'octagonal',
|
||||
'sixteen-sided',
|
||||
])
|
||||
|
||||
function columnHeightHandle(): HandleDescriptor<ColumnNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_COLUMN_HEIGHT,
|
||||
currentValue: (n) => n.height,
|
||||
apply: (_n, newValue) => ({ height: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, n.height + HEIGHT_HANDLE_OFFSET, 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function columnRadiusHandle(): HandleDescriptor<ColumnNodeType> {
|
||||
return {
|
||||
kind: 'radial-resize',
|
||||
axis: 'x',
|
||||
min: MIN_COLUMN_RADIUS,
|
||||
currentValue: (n) => n.radius,
|
||||
apply: (_n, newValue) => ({ radius: newValue }),
|
||||
placement: {
|
||||
position: (n) => [n.radius + SIDE_HANDLE_OFFSET, n.height / 2, 0],
|
||||
},
|
||||
// Guide ring traces the column's footprint at mid-height while the
|
||||
// user is hovering or dragging the radius arrow — clarifies which
|
||||
// edge the drag controls on round / octagonal / sixteen-sided shafts.
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) => n.radius + SIDE_HANDLE_OFFSET * 0.5,
|
||||
y: (n) => n.height / 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function columnAxisHandle(axis: 'x' | 'z'): HandleDescriptor<ColumnNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis,
|
||||
anchor: 'center',
|
||||
min: axis === 'x' ? MIN_COLUMN_WIDTH : MIN_COLUMN_DEPTH,
|
||||
currentValue: (n) => (axis === 'x' ? n.width : n.depth),
|
||||
apply: (_n, newValue) => (axis === 'x' ? { width: newValue } : { depth: newValue }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const half = axis === 'x' ? n.width / 2 : n.depth / 2
|
||||
return axis === 'x'
|
||||
? [half + SIDE_HANDLE_OFFSET, n.height / 2, 0]
|
||||
: [0, n.height / 2, half + SIDE_HANDLE_OFFSET]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function columnUniformHandle(): HandleDescriptor<ColumnNodeType> {
|
||||
// Square columns keep width === depth. We anchor the arrow on the +X
|
||||
// side and write BOTH fields from the same delta.
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: 'center',
|
||||
min: MIN_COLUMN_WIDTH,
|
||||
currentValue: (n) => n.width,
|
||||
apply: (_n, newValue) => ({ width: newValue, depth: newValue }),
|
||||
placement: {
|
||||
position: (n) => [n.width / 2 + SIDE_HANDLE_OFFSET, n.height / 2, 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom-spread arrow — sits just outside the right foot of the splay
|
||||
// (a-frame / x-brace etc.), one beam-width above the floor plate so it
|
||||
// clears the support legs. Drags symmetrically: anchor='center' so
|
||||
// pointer Δ of d grows the full leg-to-leg distance by 2d, both legs
|
||||
// moving ±d. Defaults mirror the renderer fall-throughs.
|
||||
function columnBraceBottomSpreadHandle(): HandleDescriptor<ColumnNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: 'center',
|
||||
min: MIN_BRACE_BOTTOM_SPREAD,
|
||||
currentValue: (n) =>
|
||||
Math.max(MIN_BRACE_BOTTOM_SPREAD, n.braceBottomSpread ?? Math.max(n.width * 3, 1.2)),
|
||||
apply: (_n, newValue) => ({ braceBottomSpread: newValue }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const spread = Math.max(
|
||||
MIN_BRACE_BOTTOM_SPREAD,
|
||||
n.braceBottomSpread ?? Math.max(n.width * 3, 1.2),
|
||||
)
|
||||
return [spread / 2 + SPREAD_HANDLE_OFFSET, 0.08, 0]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Top-spread arrow — at the right tip of the brace's top edge. Same
|
||||
// symmetric anchor as the bottom spread. For a-frame this is the small
|
||||
// "pinch" at the top; for y/v-frame and x-brace it's the wider opening.
|
||||
function columnBraceTopSpreadHandle(): HandleDescriptor<ColumnNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: 'center',
|
||||
min: MIN_BRACE_TOP_SPREAD,
|
||||
currentValue: (n) => Math.max(MIN_BRACE_TOP_SPREAD, n.braceTopSpread ?? 0.12),
|
||||
apply: (_n, newValue) => ({ braceTopSpread: newValue }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const spread = Math.max(MIN_BRACE_TOP_SPREAD, n.braceTopSpread ?? 0.12)
|
||||
return [spread / 2 + SPREAD_HANDLE_OFFSET, n.height + 0.08, 0]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function columnBraceHandle(axis: 'x' | 'z'): HandleDescriptor<ColumnNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis,
|
||||
anchor: 'center',
|
||||
min: MIN_BRACE_DIMENSION,
|
||||
currentValue: (n) =>
|
||||
axis === 'x' ? (n.braceWidth ?? n.width) : (n.braceDepth ?? n.depth),
|
||||
apply: (_n, newValue) =>
|
||||
axis === 'x' ? { braceWidth: newValue } : { braceDepth: newValue },
|
||||
placement: {
|
||||
position: (n) => {
|
||||
// Position outside any splay so the arrow clears the legs.
|
||||
const half =
|
||||
axis === 'x'
|
||||
? Math.max(
|
||||
n.braceBottomSpread ?? 0,
|
||||
n.braceTopSpread ?? 0,
|
||||
n.braceWidth ?? n.width,
|
||||
) / 2
|
||||
: (n.braceDepth ?? n.depth) / 2
|
||||
return axis === 'x'
|
||||
? [half + BRACE_HANDLE_OFFSET, n.height / 2, 0]
|
||||
: [0, n.height / 2, half + BRACE_HANDLE_OFFSET]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Which supports surface bottom-spread / top-spread in the renderer. Phase
|
||||
// 1 covers the simple two-leg / single-foot supports; x-brace / k-brace /
|
||||
// tripod / trestle / portal-frame / box-frame will hook the same handles
|
||||
// in a follow-up once we audit their leg geometry.
|
||||
const STYLES_WITH_BOTTOM_SPREAD = new Set<ColumnNodeType['supportStyle']>(['a-frame'])
|
||||
const STYLES_WITH_TOP_SPREAD = new Set<ColumnNodeType['supportStyle']>([
|
||||
'a-frame',
|
||||
'y-frame',
|
||||
'v-frame',
|
||||
])
|
||||
|
||||
// Resolve the column's visible XZ footprint half-extents per supportStyle
|
||||
// + crossSection. Vertical supports use the shaft geometry (radius for
|
||||
// round / octagonal / sixteen-sided, width/depth for square / rectangular);
|
||||
// non-vertical supports fall back to the widest sensible brace bound so
|
||||
// the rotation handle clears the splay.
|
||||
function columnFootprintHalf(n: ColumnNodeType): { halfX: number; halfZ: number } {
|
||||
if (n.supportStyle === 'vertical') {
|
||||
if (ROUND_CROSS_SECTIONS.has(n.crossSection)) {
|
||||
return { halfX: n.radius, halfZ: n.radius }
|
||||
}
|
||||
if (n.crossSection === 'square') {
|
||||
return { halfX: n.width / 2, halfZ: n.width / 2 }
|
||||
}
|
||||
return { halfX: n.width / 2, halfZ: n.depth / 2 }
|
||||
}
|
||||
return {
|
||||
halfX:
|
||||
Math.max(
|
||||
n.width,
|
||||
n.braceWidth ?? 0,
|
||||
n.braceBottomSpread ?? 0,
|
||||
n.braceTopSpread ?? 0,
|
||||
) / 2,
|
||||
halfZ: Math.max(n.depth, n.braceDepth ?? 0) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-column rotation gizmo — same pattern as the elevator. Curved
|
||||
// two-headed arrow at the +X / +Z corner of the footprint, a guide ring
|
||||
// at the corner-diagonal radius on hover/drag. `apply` negates the
|
||||
// angular delta so dragging the cursor CCW around the column rotates
|
||||
// the column CCW (cursor atan2 ticks opposite-handed from three.js Ry).
|
||||
function columnRotateHandle(): HandleDescriptor<ColumnNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||
placement: {
|
||||
// Offset along +Z only so the gizmo sticks out the front of the
|
||||
// column rather than diagonally at the corner — keeps the rotate
|
||||
// arrow from crowding the +X side handles (radius / axis / uniform)
|
||||
// while still reading as attached to the column.
|
||||
position: (n) => {
|
||||
const { halfX, halfZ } = columnFootprintHalf(n)
|
||||
const yMid = Math.max(n.height, MIN_COLUMN_HEIGHT) / 2
|
||||
return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET]
|
||||
},
|
||||
// Fixed −45° tilt — leans the curve clockwise (as seen from above)
|
||||
// toward the column's front face.
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) => {
|
||||
const { halfX, halfZ } = columnFootprintHalf(n)
|
||||
return Math.hypot(halfX, halfZ) + ROTATE_RING_OFFSET
|
||||
},
|
||||
y: (n) => Math.max(n.height, MIN_COLUMN_HEIGHT) / 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[] {
|
||||
// 1. Height (universal).
|
||||
// 2. Footprint arrows depending on supportStyle + crossSection:
|
||||
// - non-vertical supports → braceWidth + braceDepth (skips crossSection)
|
||||
// plus per-style spread arrows at the splay endpoints.
|
||||
// - round / octagonal / sixteen-sided → single radius arrow
|
||||
// - square → uniform width+depth
|
||||
// - rectangular → width + depth (independent)
|
||||
const handles: HandleDescriptor<ColumnNodeType>[] = [columnHeightHandle()]
|
||||
if (node.supportStyle !== 'vertical') {
|
||||
handles.push(columnBraceHandle('x'), columnBraceHandle('z'))
|
||||
if (STYLES_WITH_BOTTOM_SPREAD.has(node.supportStyle)) {
|
||||
handles.push(columnBraceBottomSpreadHandle())
|
||||
}
|
||||
if (STYLES_WITH_TOP_SPREAD.has(node.supportStyle)) {
|
||||
handles.push(columnBraceTopSpreadHandle())
|
||||
}
|
||||
} else if (ROUND_CROSS_SECTIONS.has(node.crossSection)) {
|
||||
handles.push(columnRadiusHandle())
|
||||
} else if (node.crossSection === 'square') {
|
||||
handles.push(columnUniformHandle())
|
||||
} else {
|
||||
handles.push(columnAxisHandle('x'), columnAxisHandle('z'))
|
||||
}
|
||||
handles.push(columnRotateHandle())
|
||||
return handles
|
||||
}
|
||||
|
||||
/**
|
||||
* Column — Stage A registration. Wrap-export of the legacy
|
||||
* `ColumnRenderer` (no system — column geometry is computed inline in
|
||||
@@ -54,6 +326,7 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
},
|
||||
|
||||
parametrics: columnParametrics,
|
||||
handles: columnHandles,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
@@ -66,6 +339,19 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
floorplan: buildColumnFloorplan,
|
||||
// 2D drag affordances — `column-resize` handles every dimension arrow
|
||||
// the floor-plan builder emits per cross-section / support style (the
|
||||
// payload's `dim` field discriminates radius / uniform / width / depth
|
||||
// / brace-width / brace-depth / spreads). `column-rotate` powers the
|
||||
// corner rotate-arrow. Body move continues to flow through the
|
||||
// orange move-handle dot via the registry overlay's generic
|
||||
// free-translate path — columns don't need a kind-specific
|
||||
// `floorplanMoveTarget` since they have no linked-cascade
|
||||
// requirements like wall.
|
||||
floorplanAffordances: {
|
||||
'column-resize': columnResizeAffordance,
|
||||
'column-rotate': columnRotateAffordance,
|
||||
},
|
||||
|
||||
presentation: {
|
||||
label: 'Column',
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ColumnNode,
|
||||
type FloorplanAffordance,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
// Floor minimums — mirror the 3D handles in `column/definition.ts` so a
|
||||
// drag can't push a value past what the renderer accepts.
|
||||
const MIN_COLUMN_WIDTH = 0.1
|
||||
const MIN_COLUMN_DEPTH = 0.1
|
||||
const MIN_COLUMN_RADIUS = 0.05
|
||||
const MIN_BRACE_DIMENSION = 0.04
|
||||
const MIN_BRACE_BOTTOM_SPREAD = 0.2
|
||||
const MIN_BRACE_TOP_SPREAD = 0
|
||||
|
||||
/**
|
||||
* One drag pattern for every column size handle. The arrow's outward
|
||||
* direction in plan coords (`planAxis`) is captured at emit-time; the
|
||||
* affordance projects the cursor along that axis and applies the delta
|
||||
* to the dimension named by `dim`.
|
||||
*
|
||||
* Sign / factor mirror the 3D `column/definition.ts` handles:
|
||||
*
|
||||
* - `width` / `depth` / `uniform` / `brace-width` / `brace-depth` /
|
||||
* `brace-bottom-spread` / `brace-top-spread`: `anchor: 'center'` —
|
||||
* a cursor delta of `d` along the outward axis grows the dimension
|
||||
* by `2·d` (both faces move ±d so the centre stays put).
|
||||
* - `radius`: `kind: 'radial-resize'` — cursor delta `d` grows the
|
||||
* radius by `d` (the visible edge follows the cursor 1:1).
|
||||
*/
|
||||
export type ColumnResizePayload = {
|
||||
dim:
|
||||
| 'width'
|
||||
| 'depth'
|
||||
| 'uniform'
|
||||
| 'radius'
|
||||
| 'brace-width'
|
||||
| 'brace-depth'
|
||||
| 'brace-bottom-spread'
|
||||
| 'brace-top-spread'
|
||||
planAxis: [number, number]
|
||||
}
|
||||
|
||||
export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
start({ node, payload, initialPlanPoint }) {
|
||||
const { dim, planAxis } = payload as ColumnResizePayload
|
||||
const columnId = node.id as AnyNodeId
|
||||
const [ax, ay] = planAxis
|
||||
const initialProj = initialPlanPoint[0] * ax + initialPlanPoint[1] * ay
|
||||
const initialWidth = node.width
|
||||
const initialDepth = node.depth
|
||||
const initialRadius = node.radius
|
||||
const initialBraceWidth = node.braceWidth ?? node.width
|
||||
const initialBraceDepth = node.braceDepth ?? node.depth
|
||||
const initialBraceBottomSpread =
|
||||
node.braceBottomSpread ?? Math.max(node.width * 3, 1.2)
|
||||
const initialBraceTopSpread = node.braceTopSpread ?? 0.12
|
||||
|
||||
let lastPatch: Partial<ColumnNode> = {}
|
||||
|
||||
const commitPatch = (patch: Partial<ColumnNode>) => {
|
||||
lastPatch = patch
|
||||
useScene.getState().updateNode(columnId, patch)
|
||||
}
|
||||
|
||||
return {
|
||||
affectedIds: [columnId],
|
||||
apply({ planPoint }) {
|
||||
const currentProj = planPoint[0] * ax + planPoint[1] * ay
|
||||
const projDelta = currentProj - initialProj
|
||||
switch (dim) {
|
||||
case 'width':
|
||||
commitPatch({
|
||||
width: Math.max(MIN_COLUMN_WIDTH, initialWidth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'depth':
|
||||
commitPatch({
|
||||
depth: Math.max(MIN_COLUMN_DEPTH, initialDepth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'uniform': {
|
||||
const next = Math.max(MIN_COLUMN_WIDTH, initialWidth + 2 * projDelta)
|
||||
commitPatch({ width: next, depth: next })
|
||||
return
|
||||
}
|
||||
case 'radius':
|
||||
commitPatch({
|
||||
radius: Math.max(MIN_COLUMN_RADIUS, initialRadius + projDelta),
|
||||
})
|
||||
return
|
||||
case 'brace-width':
|
||||
commitPatch({
|
||||
braceWidth: Math.max(MIN_BRACE_DIMENSION, initialBraceWidth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'brace-depth':
|
||||
commitPatch({
|
||||
braceDepth: Math.max(MIN_BRACE_DIMENSION, initialBraceDepth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'brace-bottom-spread':
|
||||
commitPatch({
|
||||
braceBottomSpread: Math.max(
|
||||
MIN_BRACE_BOTTOM_SPREAD,
|
||||
initialBraceBottomSpread + 2 * projDelta,
|
||||
),
|
||||
})
|
||||
return
|
||||
case 'brace-top-spread':
|
||||
commitPatch({
|
||||
braceTopSpread: Math.max(
|
||||
MIN_BRACE_TOP_SPREAD,
|
||||
initialBraceTopSpread + 2 * projDelta,
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
if (Object.keys(lastPatch).length > 0) {
|
||||
useScene.getState().updateNode(columnId, lastPatch)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Column rotation drag (floor-plan). Sister to the 3D
|
||||
* `columnRotateHandle` (arc-resize). Same `- delta` convention as the
|
||||
* 3D handle: the floor-plan builder plots the footprint at
|
||||
* `-column.rotation` (see `buildColumnFloorplan`'s `rot = -node.rotation`),
|
||||
* so the 2D view rotates the same direction as 3D for the same
|
||||
* `rotation` value, and the same cursor gesture writes the same sign
|
||||
* in both views.
|
||||
*/
|
||||
export const columnRotateAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
start({ node, initialPlanPoint }) {
|
||||
const columnId = node.id as AnyNodeId
|
||||
const initialRotation = node.rotation ?? 0
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
|
||||
let lastRotation = initialRotation
|
||||
|
||||
return {
|
||||
affectedIds: [columnId],
|
||||
apply({ planPoint }) {
|
||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
||||
let delta = currentAngle - initialAngle
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
const newRotation = initialRotation - delta
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(columnId, { rotation: newRotation })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(columnId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -4,6 +4,19 @@ import type {
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
} from '@pascal-app/core'
|
||||
import type { ColumnResizePayload } from './floorplan-affordances'
|
||||
|
||||
// Offsets for the floor-plan selection arrows. Resize chevrons hug the
|
||||
// footprint a hair off the rim; the rotate-arrow corner sits a bit
|
||||
// further out so it doesn't crowd the resize arrows.
|
||||
const RESIZE_ARROW_OFFSET = 0.12
|
||||
const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
||||
|
||||
const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([
|
||||
'round',
|
||||
'octagonal',
|
||||
'sixteen-sided',
|
||||
])
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for column. Inlined from the legacy
|
||||
@@ -13,9 +26,9 @@ import type {
|
||||
* a-frame / x-brace / etc.) — brace supports use a rotated rectangle
|
||||
* spanning the base spread; standalone columns use the shaft profile.
|
||||
*
|
||||
* When selected, switches to a themed accent stroke and emits a move
|
||||
* handle at the column center. No dimension overlay (columns don't
|
||||
* have a natural "length" axis like a wall).
|
||||
* When selected, switches to a themed accent stroke and emits the
|
||||
* orange move-handle dot, four perpendicular side move-arrows for
|
||||
* dragging the body, and a rotate-arrow at the front-right corner.
|
||||
*/
|
||||
export function buildColumnFloorplan(
|
||||
node: ColumnNode,
|
||||
@@ -56,12 +69,95 @@ export function buildColumnFloorplan(
|
||||
})
|
||||
}
|
||||
|
||||
// Move handle at the column center when selected.
|
||||
// Selection chrome — move-handle dot at the centre (body move), one
|
||||
// resize chevron per dimension the 3D handle set exposes (radius /
|
||||
// uniform / width+depth or brace-width+brace-depth + per-style spread
|
||||
// arrows), and a rotate-arrow at the front-right corner. Mirrors
|
||||
// `column/definition.ts`'s handle selection so what users can
|
||||
// manipulate in 3D top-view is the same in the floor plan.
|
||||
if (isSelected) {
|
||||
children.push({
|
||||
kind: 'move-handle',
|
||||
point: [node.position[0], node.position[2]],
|
||||
})
|
||||
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
// Floor-plan plots at `-rotation` so SVG-CW maps to Three.js-CCW.
|
||||
// Selection chrome (side arrows, rotate-arrow) follows the same
|
||||
// convention so it stays glued to the rotated footprint.
|
||||
const rot = -node.rotation
|
||||
const emitArrowAlong = (
|
||||
dim: ColumnResizePayload['dim'],
|
||||
localDirection: 'x' | 'z',
|
||||
localOffsetDistance: number,
|
||||
) => {
|
||||
// Local position of the arrow's chevron tip relative to the
|
||||
// column centre. The chevron sits a hair past the dimension's
|
||||
// current half-extent so it never overlaps the footprint stroke.
|
||||
const localPos: [number, number] =
|
||||
localDirection === 'x' ? [localOffsetDistance, 0] : [0, localOffsetDistance]
|
||||
const [worldOffsetX, worldOffsetZ] = rotatePlanVector(localPos[0], localPos[1], rot)
|
||||
// The cursor projection axis: same direction as the arrow's
|
||||
// outward tip in plan coords. Captured at emit-time so the
|
||||
// affordance doesn't need to recompute `column.rotation` (and
|
||||
// a mid-drag rotation can't drift the projection basis).
|
||||
const outwardLocal: [number, number] =
|
||||
localDirection === 'x' ? [1, 0] : [0, 1]
|
||||
const [planAxisX, planAxisY] = rotatePlanVector(outwardLocal[0], outwardLocal[1], rot)
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [cx + worldOffsetX, cz + worldOffsetZ],
|
||||
angle: Math.atan2(planAxisY, planAxisX),
|
||||
affordance: 'column-resize',
|
||||
payload: { dim, planAxis: [planAxisX, planAxisY] } satisfies ColumnResizePayload,
|
||||
})
|
||||
}
|
||||
|
||||
if (node.supportStyle !== 'vertical') {
|
||||
// Brace columns — width + depth of the bracing structure. Spread
|
||||
// arrows (top + bottom) project to the same XZ in top-view, so
|
||||
// we only surface bracing dimensions here. The 3D set still has
|
||||
// spread arrows at different heights.
|
||||
const halfBraceX =
|
||||
Math.max(
|
||||
node.width,
|
||||
node.braceWidth ?? 0,
|
||||
node.braceBottomSpread ?? 0,
|
||||
node.braceTopSpread ?? 0,
|
||||
) / 2
|
||||
const halfBraceZ = Math.max(node.depth, node.braceDepth ?? 0) / 2
|
||||
emitArrowAlong('brace-width', 'x', halfBraceX + RESIZE_ARROW_OFFSET)
|
||||
emitArrowAlong('brace-depth', 'z', halfBraceZ + RESIZE_ARROW_OFFSET)
|
||||
} else if (ROUND_CROSS_SECTIONS.has(node.crossSection)) {
|
||||
// Round shafts — single radius arrow. `radial-resize` factor 1
|
||||
// (cursor delta = radius delta), so dragging the chevron
|
||||
// outward 1 unit grows the column by 1 unit.
|
||||
emitArrowAlong('radius', 'x', node.radius + RESIZE_ARROW_OFFSET)
|
||||
} else if (node.crossSection === 'square') {
|
||||
// Square shafts — single uniform arrow that grows width + depth
|
||||
// together (matches 3D `columnUniformHandle`).
|
||||
emitArrowAlong('uniform', 'x', node.width / 2 + RESIZE_ARROW_OFFSET)
|
||||
} else {
|
||||
// Rectangular — independent width + depth arrows.
|
||||
emitArrowAlong('width', 'x', node.width / 2 + RESIZE_ARROW_OFFSET)
|
||||
emitArrowAlong('depth', 'z', node.depth / 2 + RESIZE_ARROW_OFFSET)
|
||||
}
|
||||
|
||||
// Rotate-arrow at the +X / +Z corner — matches the 3D
|
||||
// `columnRotateHandle` corner placement so users see the rotation
|
||||
// affordance in the same quadrant across views.
|
||||
const { halfX, halfZ } = columnPlanHalfExtents(node)
|
||||
const cornerLocalX = halfX + ROTATE_ARROW_CORNER_OFFSET
|
||||
const cornerLocalZ = halfZ + ROTATE_ARROW_CORNER_OFFSET
|
||||
const [cornerWorldX, cornerWorldZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, rot)
|
||||
const [radialX, radialZ] = rotatePlanVector(1, 1, rot)
|
||||
children.push({
|
||||
kind: 'rotate-arrow',
|
||||
point: [cx + cornerWorldX, cz + cornerWorldZ],
|
||||
angle: Math.atan2(radialZ, radialX),
|
||||
affordance: 'column-rotate',
|
||||
})
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
@@ -71,6 +167,39 @@ export function buildColumnFloorplan(
|
||||
|
||||
type PlanPoint = { x: number; y: number }
|
||||
|
||||
/**
|
||||
* XZ half-extents for the column's plan footprint. Mirrors the 3D
|
||||
* `columnFootprintHalf` so side-arrows + rotate-arrow ring the same
|
||||
* bounding box the in-world handles use. Vertical supports read the
|
||||
* shaft geometry; non-vertical supports use the widest brace bound so
|
||||
* arrows clear the splay.
|
||||
*/
|
||||
function columnPlanHalfExtents(column: ColumnNode): { halfX: number; halfZ: number } {
|
||||
if (column.supportStyle !== 'vertical') {
|
||||
return {
|
||||
halfX:
|
||||
Math.max(
|
||||
column.width,
|
||||
column.braceWidth ?? 0,
|
||||
column.braceBottomSpread ?? 0,
|
||||
column.braceTopSpread ?? 0,
|
||||
) / 2,
|
||||
halfZ: Math.max(column.depth, column.braceDepth ?? 0) / 2,
|
||||
}
|
||||
}
|
||||
if (
|
||||
column.crossSection === 'round' ||
|
||||
column.crossSection === 'octagonal' ||
|
||||
column.crossSection === 'sixteen-sided'
|
||||
) {
|
||||
return { halfX: column.radius, halfZ: column.radius }
|
||||
}
|
||||
if (column.crossSection === 'square') {
|
||||
return { halfX: column.width / 2, halfZ: column.width / 2 }
|
||||
}
|
||||
return { halfX: column.width / 2, halfZ: column.depth / 2 }
|
||||
}
|
||||
|
||||
function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
|
||||
const c = Math.cos(rotation)
|
||||
const s = Math.sin(rotation)
|
||||
@@ -147,7 +276,7 @@ function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
|
||||
column.braceDepth ?? column.depth,
|
||||
0.08,
|
||||
)
|
||||
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
|
||||
return getRotatedRectanglePolygon(center, width, depth, -column.rotation)
|
||||
}
|
||||
|
||||
// Standalone column: shaft profile expanded for base + capital.
|
||||
@@ -169,7 +298,7 @@ function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
|
||||
)
|
||||
|
||||
if (column.crossSection === 'square' || column.crossSection === 'rectangular') {
|
||||
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
|
||||
return getRotatedRectanglePolygon(center, width, depth, -column.rotation)
|
||||
}
|
||||
|
||||
const segmentCount =
|
||||
@@ -179,7 +308,7 @@ function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
|
||||
const angle = (index / segmentCount) * Math.PI * 2
|
||||
const localX = Math.cos(angle) * (width / 2)
|
||||
const localY = Math.sin(angle) * (depth / 2)
|
||||
const [offsetX, offsetY] = rotatePlanVector(localX, localY, column.rotation)
|
||||
const [offsetX, offsetY] = rotatePlanVector(localX, localY, -column.rotation)
|
||||
return { x: center.x + offsetX, y: center.y + offsetY }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,6 +96,83 @@ const SUPPORT_STYLE_OPTIONS: Array<{ label: string; value: ColumnNode['supportSt
|
||||
{ label: 'Box Frame', value: 'box-frame' },
|
||||
]
|
||||
|
||||
type NonVerticalSupportStyle = Exclude<ColumnNode['supportStyle'], 'vertical'>
|
||||
|
||||
// Per-style brace defaults. Values mirror each support's renderer
|
||||
// fall-through expressions so switching styles snaps the column to the
|
||||
// shape that style was designed around — e.g. an A-frame opens wide at
|
||||
// the foot and pinches at the top, an X-brace runs parallel legs, a
|
||||
// tripod's "bottomSpread" / "topSpread" double as its X-span / Z-span.
|
||||
// Without these, a user who customised one style (say A-frame bottom =
|
||||
// 2.0) then switched to Y-frame would carry that 2.0 around in state
|
||||
// even though Y-frame doesn't use it — and switching back to X-brace
|
||||
// would inherit the leftover 0.12 top from A-frame, making the X look
|
||||
// pinched.
|
||||
const SUPPORT_STYLE_DEFAULTS: Record<NonVerticalSupportStyle, Partial<ColumnNode>> = {
|
||||
'a-frame': {
|
||||
braceBottomSpread: 1.4,
|
||||
braceTopSpread: 0.12,
|
||||
braceWidth: 0.16,
|
||||
braceDepth: 0.16,
|
||||
},
|
||||
'y-frame': {
|
||||
braceBottomSpread: 0.2,
|
||||
braceTopSpread: 0.9,
|
||||
braceWidth: 0.16,
|
||||
braceDepth: 0.16,
|
||||
},
|
||||
'v-frame': {
|
||||
braceBottomSpread: 0.2,
|
||||
braceTopSpread: 1.0,
|
||||
braceWidth: 0.16,
|
||||
braceDepth: 0.16,
|
||||
},
|
||||
'x-brace': {
|
||||
braceBottomSpread: 1.0,
|
||||
braceTopSpread: 1.0,
|
||||
braceWidth: 0.14,
|
||||
braceDepth: 0.14,
|
||||
},
|
||||
'k-brace': {
|
||||
braceBottomSpread: 1.0,
|
||||
braceTopSpread: 1.0,
|
||||
braceWidth: 0.14,
|
||||
braceDepth: 0.14,
|
||||
},
|
||||
'single-strut': {
|
||||
braceBottomSpread: 0.6,
|
||||
braceTopSpread: 0.6,
|
||||
braceWidth: 0.12,
|
||||
braceDepth: 0.12,
|
||||
},
|
||||
tripod: {
|
||||
// bottomSpread = X span, topSpread = Z span (tripod's three legs).
|
||||
braceBottomSpread: 1.1,
|
||||
braceTopSpread: 1.1,
|
||||
braceWidth: 0.12,
|
||||
braceDepth: 0.12,
|
||||
},
|
||||
trestle: {
|
||||
braceBottomSpread: 1.2,
|
||||
braceTopSpread: 1.0,
|
||||
braceWidth: 0.16,
|
||||
braceDepth: 0.16,
|
||||
},
|
||||
'portal-frame': {
|
||||
braceBottomSpread: 1.4,
|
||||
braceTopSpread: 1.0,
|
||||
braceWidth: 0.16,
|
||||
braceDepth: 0.16,
|
||||
},
|
||||
'box-frame': {
|
||||
// bottomSpread = X span, topSpread = Z span (rectangular footprint).
|
||||
braceBottomSpread: 1.4,
|
||||
braceTopSpread: 1.0,
|
||||
braceWidth: 0.16,
|
||||
braceDepth: 0.16,
|
||||
},
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
@@ -260,19 +337,27 @@ export default function ColumnPanel() {
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Shape">
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2 px-1 pt-1">
|
||||
{SUPPORT_STYLE_OPTIONS.map((option) => {
|
||||
const isSelected = supportStyle === option.value
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
|
||||
'flex min-h-12 items-center rounded-lg border px-3 py-2.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||
)}
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
// Per-style brace defaults reset the column to that
|
||||
// style's natural proportions on switch. Spread last
|
||||
// so the preset's braceWidth / braceDepth win over
|
||||
// the carried-from-previous-style values.
|
||||
const stylePreset =
|
||||
option.value === 'vertical'
|
||||
? {}
|
||||
: SUPPORT_STYLE_DEFAULTS[option.value]
|
||||
handleUpdate({
|
||||
supportStyle: option.value,
|
||||
...(option.value !== 'vertical'
|
||||
@@ -284,8 +369,9 @@ export default function ColumnPanel() {
|
||||
capitalStyle: 'none',
|
||||
}
|
||||
: {}),
|
||||
...stylePreset,
|
||||
})
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
|
||||
import {
|
||||
type ColumnNode,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
baseMaterial,
|
||||
type ColorPreset,
|
||||
@@ -2071,8 +2076,17 @@ function Capital({ node, y, height }: { node: ColumnNode; y: number; height: num
|
||||
)
|
||||
}
|
||||
|
||||
export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
|
||||
export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
// Merge any live drag override so width / depth / radius / height
|
||||
// arrows update the mesh on every pointer move, with zustand only
|
||||
// hearing the commit on release. Subscribes narrowly to this node's
|
||||
// override entry; unrelated writes don't re-render.
|
||||
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(rawNode.id))
|
||||
const node = useMemo<ColumnNode>(
|
||||
() => (liveOverride ? ({ ...rawNode, ...liveOverride } as ColumnNode) : rawNode),
|
||||
[rawNode, liveOverride],
|
||||
)
|
||||
const handlers = useNodeEvents(node, 'column')
|
||||
const liveTransform = useLiveTransforms((state) => state.get(node.id))
|
||||
const shading = useViewer((state) => state.shading)
|
||||
|
||||
@@ -1,9 +1,108 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode as DoorNodeType,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { doorWidthAffordance } from './floorplan-affordances'
|
||||
import { buildDoorFloorplan } from './floorplan'
|
||||
import { doorFloorplanMoveTarget } from './floorplan-move'
|
||||
import { doorParametrics } from './parametrics'
|
||||
import { DoorNode } from './schema'
|
||||
|
||||
const SIDE_HANDLE_OFFSET = 0.24
|
||||
const HEIGHT_HANDLE_OFFSET = 0.24
|
||||
const MIN_DOOR_HEIGHT = 0.5
|
||||
const MIN_DOOR_WIDTH = 0.3
|
||||
|
||||
function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
||||
if (!door.wallId) return Number.POSITIVE_INFINITY
|
||||
const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined
|
||||
if (!wall) return Number.POSITIVE_INFINITY
|
||||
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
}
|
||||
|
||||
function readWallHeight(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
||||
if (!door.wallId) return Number.POSITIVE_INFINITY
|
||||
const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined
|
||||
return wall?.height ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
// Width arrow on the door-local +X (right) or -X (left) side. Drag grows
|
||||
// the door from the anchored OPPOSITE edge; the door's wall-local center
|
||||
// re-centers so the anchored edge stays put.
|
||||
function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor<DoorNodeType> {
|
||||
const sign = side === 'right' ? 1 : -1
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
// 'min' = -X edge anchored (right arrow grows the +X edge outward).
|
||||
// 'max' = +X edge anchored (left arrow grows the -X edge outward).
|
||||
anchor: side === 'right' ? 'min' : 'max',
|
||||
min: MIN_DOOR_WIDTH,
|
||||
max: (n, scene) => readWallLength(n, scene),
|
||||
currentValue: (n) => n.width,
|
||||
apply: (initial, newWidth) => {
|
||||
// Anchored edge stays fixed in wall-local coords. Door rotation is
|
||||
// applied by the inner ride group (the renderer mounts a nested
|
||||
// <group> at the door's pose), so the apply math here is in
|
||||
// door-local coords AND the patch.position must be in wall-local
|
||||
// coords. We compute the anchored wall-local point from the
|
||||
// initial node, then derive the new wall-local center from it.
|
||||
const rotY = initial.rotation[1]
|
||||
const armX = Math.cos(rotY)
|
||||
const armZ = -Math.sin(rotY)
|
||||
const anchorX = initial.position[0] - sign * (initial.width / 2) * armX
|
||||
const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ
|
||||
const newCenterX = anchorX + sign * (newWidth / 2) * armX
|
||||
const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ
|
||||
return {
|
||||
width: newWidth,
|
||||
position: [newCenterX, initial.position[1], newCenterZ],
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
// door-local: +X axis lives along door's own X. Inner ride group
|
||||
// applies door.rotation, so we sit purely on door-local +X / -X.
|
||||
position: (n) => [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), 0, 0],
|
||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||
},
|
||||
portal: 'grandparent',
|
||||
}
|
||||
}
|
||||
|
||||
function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min', // bottom anchored at wall-local Y = position[1] - height/2
|
||||
min: MIN_DOOR_HEIGHT,
|
||||
max: (n, scene) => {
|
||||
const bottom = n.position[1] - n.height / 2
|
||||
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
|
||||
},
|
||||
currentValue: (n) => n.height,
|
||||
apply: (initial, newHeight) => {
|
||||
const bottom = initial.position[1] - initial.height / 2
|
||||
return {
|
||||
height: newHeight,
|
||||
position: [initial.position[0], bottom + newHeight / 2, initial.position[2]],
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n) => [0, n.height / 2 + HEIGHT_HANDLE_OFFSET, 0],
|
||||
},
|
||||
portal: 'grandparent',
|
||||
}
|
||||
}
|
||||
|
||||
const doorHandles: HandleDescriptor<DoorNodeType>[] = [
|
||||
doorWidthHandle('left'),
|
||||
doorWidthHandle('right'),
|
||||
doorHeightHandle(),
|
||||
]
|
||||
|
||||
/**
|
||||
* Door — Phase 5 batch kind. Hosted on walls, cuts holes in them,
|
||||
* animated open/close state.
|
||||
@@ -43,9 +142,11 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
wallOpeningPlacement: true,
|
||||
},
|
||||
|
||||
parametrics: doorParametrics,
|
||||
handles: doorHandles,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
@@ -78,6 +179,13 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
// local-X to 0.5m, clamps inside wall bounds.
|
||||
floorplanMoveTarget: doorFloorplanMoveTarget,
|
||||
|
||||
// 2D drag affordances. `resize-width` drives the door's two side
|
||||
// arrows — pointer-down on either arrow starts an anchored width drag
|
||||
// (opposite edge stays fixed, clamped to wall bounds).
|
||||
floorplanAffordances: {
|
||||
'resize-width': doorWidthAffordance,
|
||||
},
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place door on wall' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
const MIN_DOOR_WIDTH = 0.3
|
||||
|
||||
type DoorWidthPayload = { side: 'start' | 'end' }
|
||||
|
||||
/**
|
||||
* 2D drag affordance for the door's width side-arrows. Sister to the 3D
|
||||
* `DoorSideArrow` width drag in `packages/editor/src/components/editor/
|
||||
* door-side-handles.tsx` — both anchor at the opposite door edge and clamp
|
||||
* to wall bounds.
|
||||
*
|
||||
* Payload encodes which edge the user grabbed:
|
||||
* - `'start'`: arrow at the door edge closer to `wall.start`. The
|
||||
* opposite edge (toward `wall.end`) stays fixed.
|
||||
* - `'end'`: arrow at the edge closer to `wall.end`. The wall-start
|
||||
* edge stays fixed.
|
||||
*
|
||||
* Uses the scene-write preview pattern (writes directly to `useScene`
|
||||
* each tick): the registry layer's `effectiveNode` only merges live
|
||||
* overrides for walls, so an override-based preview wouldn't show on
|
||||
* doors. The dispatcher snapshots / pauses history at start, so per-tick
|
||||
* scene writes still collapse to one undoable entry on commit.
|
||||
*/
|
||||
export const doorWidthAffordance: FloorplanAffordance<DoorNode> = {
|
||||
start({ node, payload, nodes, initialPlanPoint }): FloorplanAffordanceSession {
|
||||
const { side } = payload as DoorWidthPayload
|
||||
const doorId = node.id as AnyNodeId
|
||||
const wall = node.wallId ? (nodes[node.wallId as AnyNodeId] as WallNode | undefined) : undefined
|
||||
|
||||
const initialWidth = node.width
|
||||
const initialDoorX = node.position[0]
|
||||
const initialDoorY = node.position[1]
|
||||
const initialDoorZ = node.position[2]
|
||||
|
||||
// Anchor (wall-local X) is the door edge OPPOSITE to the dragged
|
||||
// side. Grow direction along the wall: +1 for 'end' (drag outward
|
||||
// toward wall.end), -1 for 'start' (drag outward toward wall.start).
|
||||
const growDir = side === 'end' ? 1 : -1
|
||||
const anchorX =
|
||||
side === 'end' ? initialDoorX - initialWidth / 2 : initialDoorX + initialWidth / 2
|
||||
|
||||
// Wall axis (level-local) for projecting pointer movement to a
|
||||
// wall-local X delta.
|
||||
const wallStart: readonly [number, number] = wall ? wall.start : [0, 0]
|
||||
const wallEnd: readonly [number, number] = wall ? wall.end : [1, 0]
|
||||
const dx = wallEnd[0] - wallStart[0]
|
||||
const dz = wallEnd[1] - wallStart[1]
|
||||
const wallLength = Math.hypot(dx, dz) || 1
|
||||
const dirX = dx / wallLength
|
||||
const dirZ = dz / wallLength
|
||||
|
||||
// Max width keeps the dragged edge inside the wall span. With the
|
||||
// anchor fixed, the moving edge is `anchorX ± width`, so the largest
|
||||
// legal width is the headroom on the grow side.
|
||||
const maxWidth = growDir > 0 ? wallLength - anchorX : anchorX
|
||||
|
||||
const projectToWallLocalX = (planPoint: readonly [number, number]) => {
|
||||
return (planPoint[0] - wallStart[0]) * dirX + (planPoint[1] - wallStart[1]) * dirZ
|
||||
}
|
||||
|
||||
// Initial pointer in wall-local X — anchored to where the user
|
||||
// actually pressed, so any subtle offset between the arrow's visual
|
||||
// origin and the click point doesn't pre-bias the width delta.
|
||||
const initialPointerLocalX = projectToWallLocalX(initialPlanPoint)
|
||||
|
||||
let lastWidth = initialWidth
|
||||
let lastDoorX = initialDoorX
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint }) {
|
||||
const currentLocalX = projectToWallLocalX(planPoint)
|
||||
const delta = (currentLocalX - initialPointerLocalX) * growDir
|
||||
const newWidth = Math.min(
|
||||
Math.max(MIN_DOOR_WIDTH, initialWidth + delta),
|
||||
Math.max(MIN_DOOR_WIDTH, maxWidth),
|
||||
)
|
||||
const newDoorX = anchorX + growDir * (newWidth / 2)
|
||||
lastWidth = newWidth
|
||||
lastDoorX = newDoorX
|
||||
// Scene-write preview so the 2D plan + 3D viewer both pick up
|
||||
// the change immediately. The dispatcher paused history at
|
||||
// session start, so per-tick writes don't pollute undo.
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: doorId,
|
||||
data: {
|
||||
width: newWidth,
|
||||
position: [newDoorX, initialDoorY, initialDoorZ],
|
||||
},
|
||||
},
|
||||
])
|
||||
},
|
||||
canCommit() {
|
||||
// Width is always clamped to >= MIN_DOOR_WIDTH inside apply, so
|
||||
// any committed state is legal.
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
// Atomic, tracked final write. Owning the commit ourselves
|
||||
// bypasses the dispatcher's diff path (which only re-applies
|
||||
// fields that differ from the pre-drag snapshot — if the user
|
||||
// drags back to the original size by accident, the diff is empty
|
||||
// and the door would otherwise revert to its starting state).
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: doorId,
|
||||
data: {
|
||||
width: lastWidth,
|
||||
position: [lastDoorX, initialDoorY, initialDoorZ],
|
||||
},
|
||||
},
|
||||
])
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -37,6 +37,17 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
return wall ? (wall.parentId as AnyNodeId | null) : null
|
||||
})()
|
||||
|
||||
// Track the last successful placement so `commit()` can write it
|
||||
// atomically — see the comment on `commit` below for why we don't
|
||||
// rely on the dispatcher's diff path.
|
||||
let lastValid: {
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
side: DoorNode['side']
|
||||
parentId: string
|
||||
wallId: string
|
||||
} | null = null
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
@@ -48,6 +59,14 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
|
||||
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
|
||||
|
||||
lastValid = {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: hit.wall.id,
|
||||
wallId: hit.wall.id,
|
||||
}
|
||||
|
||||
// Build the updates atomically — position + rotation + side +
|
||||
// parentId + wallId in a single scene write. The current door's
|
||||
// parent might be a different wall; re-anchoring requires moving
|
||||
@@ -56,13 +75,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: hit.wall.id,
|
||||
wallId: hit.wall.id,
|
||||
},
|
||||
data: lastValid,
|
||||
},
|
||||
])
|
||||
},
|
||||
@@ -81,6 +94,23 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
)
|
||||
return !overlapping
|
||||
},
|
||||
commit() {
|
||||
// Own the atomic write so the overlay takes the deterministic
|
||||
// commit-path (revert → resume → session.commit()). The dispatcher's
|
||||
// diff path would otherwise re-derive the final state by comparing
|
||||
// the post-apply scene to the snapshot — that works most of the
|
||||
// time, but produces an empty diff (and silent revert) when the
|
||||
// committed move happens to land on the same `parentId` AND has
|
||||
// been re-applied with identical data. Owning commit removes that
|
||||
// foot-gun without forcing the dispatcher to track per-key writes.
|
||||
if (!lastValid) return
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: lastValid,
|
||||
},
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
return session
|
||||
|
||||
@@ -183,6 +183,29 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
kind: 'move-handle',
|
||||
point: [cx, cz],
|
||||
})
|
||||
|
||||
// Width-resize arrows at each side of the door (along the wall
|
||||
// direction). Pointer-down on either routes through the door's
|
||||
// `resize-width` affordance — anchored at the opposite edge, clamped
|
||||
// to wall bounds. Mirrors the 3D `DoorSideArrow` width drag.
|
||||
const startEdgeX = cx - dirX * halfWidth
|
||||
const startEdgeZ = cz - dirZ * halfWidth
|
||||
const endEdgeX = cx + dirX * halfWidth
|
||||
const endEdgeZ = cz + dirZ * halfWidth
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [startEdgeX, startEdgeZ],
|
||||
angle: Math.atan2(-dirZ, -dirX),
|
||||
affordance: 'resize-width',
|
||||
payload: { side: 'start' },
|
||||
})
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [endEdgeX, endEdgeZ],
|
||||
angle: Math.atan2(dirZ, dirX),
|
||||
affordance: 'resize-width',
|
||||
payload: { side: 'end' },
|
||||
})
|
||||
}
|
||||
|
||||
// Placement-measurement dimensions — distances to adjacent openings
|
||||
|
||||
@@ -650,13 +650,13 @@ export default function DoorPanel() {
|
||||
/>
|
||||
</div>
|
||||
{!isOpening && (
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2 px-1 pt-1">
|
||||
{(isGarageDoor ? garageDoorTypeOptions : doorTypeOptions).map((option) => {
|
||||
const isSelected = doorType === option.value
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-12 items-center gap-2 rounded-lg border px-2.5 text-left text-xs transition-colors',
|
||||
'flex min-h-12 items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||
@@ -668,7 +668,7 @@ export default function DoorPanel() {
|
||||
onClick={() => handleUpdate(getDoorTypeUpdates(option.value))}
|
||||
type="button"
|
||||
>
|
||||
<DoorOpen className="h-3.5 w-3.5 shrink-0" />
|
||||
<DoorOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -154,8 +154,22 @@ const DoorTool: React.FC = () => {
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||
|
||||
if (draftRef.current) {
|
||||
if (event.node.id !== draftRef.current.parentId) {
|
||||
// Wall changed without enter/leave: must updateNode to reparent
|
||||
// Update the scene store on every move so the 2D floor plan
|
||||
// stays in sync (it re-renders from `node.position`). Only
|
||||
// forward `parentId` / `wallId` when the wall actually changed
|
||||
// — otherwise the reparent path churns the host wall's
|
||||
// `children` array every tick, which re-renders the wall and
|
||||
// briefly draws its 0-vertex placeholder geometry (WebGPU then
|
||||
// flags "Vertex buffer slot 0 ... was not set").
|
||||
const isSameWall = event.node.id === draftRef.current.parentId
|
||||
if (isSameWall) {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
})
|
||||
markWallDirty(event.node.id)
|
||||
} else {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
@@ -163,15 +177,6 @@ const DoorTool: React.FC = () => {
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
} else {
|
||||
// Same wall: update Three.js mesh directly to avoid store churn
|
||||
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
|
||||
if (draftMesh) {
|
||||
draftMesh.position.set(clampedX, clampedY, 0)
|
||||
draftMesh.rotation.set(0, itemRotation, 0)
|
||||
draftMesh.updateMatrixWorld(true)
|
||||
}
|
||||
markWallDirty(event.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,147 @@
|
||||
import { ElevatorNode as ElevatorNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import {
|
||||
type ElevatorNode as ElevatorNodeType,
|
||||
ElevatorNode as ElevatorNodeSchema,
|
||||
getElevatorCabDepth,
|
||||
getElevatorCabWidth,
|
||||
getElevatorShaftDepth,
|
||||
getElevatorShaftWallThickness,
|
||||
getElevatorShaftWidth,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
resolveElevatorLevels,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
elevatorResizeAffordance,
|
||||
elevatorRotateAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { buildElevatorFloorplan } from './floorplan'
|
||||
import { elevatorParametrics } from './parametrics'
|
||||
import { ElevatorNode } from './schema'
|
||||
|
||||
const SIDE_HANDLE_OFFSET = 0.22
|
||||
const HEIGHT_HANDLE_OFFSET = 0.3
|
||||
const MIN_ELEVATOR_DIM = 0.6
|
||||
const MIN_CAB_HEIGHT = 1.4
|
||||
const ROTATE_CORNER_OFFSET = 0.4
|
||||
const ROTATE_RING_OFFSET = 0.08
|
||||
|
||||
// Symmetric width / depth arrows around the cab footprint. The descriptor
|
||||
// edits the CAB dimension (`width` / `depth`) — but the arrow must sit
|
||||
// outside the SHAFT shell so it doesn't disappear inside the rendered
|
||||
// elevator. Placement uses the shaft's outer extent + wall thickness +
|
||||
// padding so the arrow clears the visible body on both `solid` and
|
||||
// `glass` shafts. `anchor: 'center'` means dragging outward grows the
|
||||
// full span 2× the pointer delta; node.position stays put.
|
||||
function elevatorAxisHandle(axis: 'x' | 'z'): HandleDescriptor<ElevatorNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis,
|
||||
anchor: 'center',
|
||||
min: MIN_ELEVATOR_DIM,
|
||||
currentValue: (n) => (axis === 'x' ? n.width : n.depth),
|
||||
apply: (_n, newValue) => (axis === 'x' ? { width: newValue } : { depth: newValue }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const cabWidth = getElevatorCabWidth(n)
|
||||
const cabDepth = getElevatorCabDepth(n)
|
||||
const wallThickness = getElevatorShaftWallThickness(n)
|
||||
const outerHalf =
|
||||
axis === 'x'
|
||||
? getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness
|
||||
: getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness
|
||||
const yMid = Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2
|
||||
return axis === 'x'
|
||||
? [outerHalf + SIDE_HANDLE_OFFSET, yMid, 0]
|
||||
: [0, yMid, outerHalf + SIDE_HANDLE_OFFSET]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Cab-height arrow — `anchor: 'min'` keeps the cab floor fixed and grows
|
||||
// the cab upward. The arrow itself sits above the full SHAFT top (not
|
||||
// just the cab) so a multi-level elevator's arrow appears outside the
|
||||
// rendered body rather than buried inside the shaft. `resolveElevatorLevels`
|
||||
// walks the building's level chain to find shaftTopY; the fallback
|
||||
// (cabHeight + 0.3) matches the renderer's own when no service levels
|
||||
// are configured yet.
|
||||
function elevatorCabHeightHandle(): HandleDescriptor<ElevatorNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_CAB_HEIGHT,
|
||||
currentValue: (n) => Math.max(n.cabHeight, MIN_CAB_HEIGHT),
|
||||
apply: (_n, newValue) => ({ cabHeight: newValue }),
|
||||
placement: {
|
||||
position: (n, scene) => {
|
||||
const { shaftTopY } = resolveElevatorLevels(n, scene.nodes())
|
||||
const cabTop = Math.max(n.cabHeight, MIN_CAB_HEIGHT) + 0.3
|
||||
const top = Math.max(shaftTopY, cabTop)
|
||||
return [0, top + HEIGHT_HANDLE_OFFSET, 0]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation handle — sits at the front-right corner of the shaft
|
||||
// footprint. `arc-resize` does the angular drag math (raycasts a
|
||||
// horizontal plane at the arrow's Y, measures cursor angle around the
|
||||
// elevator's local origin, returns the delta to apply). On hover or
|
||||
// drag the decoration ring traces the shaft's bounding circle through
|
||||
// all four corners — same idiom as the column radius ring.
|
||||
function elevatorRotateHandle(): HandleDescriptor<ElevatorNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
// The cursor delta `atan2(hit.z - center.z, hit.x - center.x)` ticks
|
||||
// up in the opposite handedness from three.js's Y-rotation (positive
|
||||
// Ry takes +X → -Z, while atan2(z,x) increases as we go +X → +Z).
|
||||
// Negate so dragging the cursor CCW around the elevator (as seen
|
||||
// from above) actually rotates the elevator CCW.
|
||||
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||
placement: {
|
||||
// Offset along +Z only so the gizmo sticks out the front of the
|
||||
// shaft rather than diagonally at the corner — matches the column's
|
||||
// one-direction rotate placement.
|
||||
position: (n) => {
|
||||
const cabWidth = getElevatorCabWidth(n)
|
||||
const cabDepth = getElevatorCabDepth(n)
|
||||
const wallThickness = getElevatorShaftWallThickness(n)
|
||||
const halfX = getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness
|
||||
const halfZ = getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness
|
||||
const yMid = Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2
|
||||
return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET]
|
||||
},
|
||||
// Fixed −45° tilt — leans the curve clockwise (as seen from above)
|
||||
// toward the shaft's front face.
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
// Bounding circle through the shaft corners — drawn slightly larger
|
||||
// so it sits outside the visible shell.
|
||||
radius: (n) => {
|
||||
const cabWidth = getElevatorCabWidth(n)
|
||||
const cabDepth = getElevatorCabDepth(n)
|
||||
const wallThickness = getElevatorShaftWallThickness(n)
|
||||
const halfX = getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness
|
||||
const halfZ = getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness
|
||||
return Math.hypot(halfX, halfZ) + ROTATE_RING_OFFSET
|
||||
},
|
||||
y: (n) => Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const elevatorHandles: HandleDescriptor<ElevatorNodeType>[] = [
|
||||
elevatorAxisHandle('x'),
|
||||
elevatorAxisHandle('z'),
|
||||
elevatorCabHeightHandle(),
|
||||
elevatorRotateHandle(),
|
||||
]
|
||||
|
||||
/**
|
||||
* Elevator — Stage A registration. Wrap-exports the legacy renderer +
|
||||
* the three legacy systems (runtime / interaction / opening) bundled
|
||||
@@ -25,11 +164,17 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
// Generic XZ translate so the floating action menu's Move button
|
||||
// (and the side move-arrows emitted from `def.floorplan`) drive the
|
||||
// 2D body-move flow through `FloorplanRegistryMoveOverlay`'s
|
||||
// Path 2 — position[0] / position[2] update with a 0.5m grid snap.
|
||||
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
parametrics: elevatorParametrics,
|
||||
handles: elevatorHandles,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
@@ -40,6 +185,21 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
|
||||
priority: 3,
|
||||
},
|
||||
floorplan: buildElevatorFloorplan,
|
||||
// Elevators are parented to the building (siblings of levels), so the
|
||||
// floor-plan layer's level-rooted DFS never reaches them. Declaring
|
||||
// `floorplanScope: 'building'` tells `FloorplanRegistryLayer` to walk
|
||||
// building-scoped kinds separately and synthesise `ctx.parent` as the
|
||||
// active level — that's what `buildElevatorFloorplan` reads via
|
||||
// `ctx.parent?.id` to decide whether this floor is in the elevator's
|
||||
// service range.
|
||||
floorplanScope: 'building',
|
||||
// 2D drag affordance for the rotate-arrow emitted at the elevator's
|
||||
// front-right corner. Body move uses the generic move-arrow / move-
|
||||
// handle path emitted by the floor-plan builder.
|
||||
floorplanAffordances: {
|
||||
'elevator-resize': elevatorResizeAffordance,
|
||||
'elevator-rotate': elevatorRotateAffordance,
|
||||
},
|
||||
|
||||
presentation: {
|
||||
label: 'Elevator',
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
type FloorplanAffordance,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
const MIN_ELEVATOR_DIM = 0.6
|
||||
|
||||
type ElevatorResizePayload = { axis: 'x' | 'z'; side: 1 | -1 }
|
||||
|
||||
/**
|
||||
* Elevator width / depth drag (floor-plan). Mirrors the 3D
|
||||
* `linear-resize` handles declared in `definition.ts` — `anchor: 'center'`
|
||||
* means dragging outward on either +X or -X edge grows `width` by 2×
|
||||
* the elevator-local cursor offset while `position` stays put. Same for
|
||||
* +Z / -Z and `depth`. Writes directly to scene each tick (door pattern);
|
||||
* the registry dispatcher snapshots / pauses history at start so the
|
||||
* per-tick writes collapse into one undoable entry on commit.
|
||||
*/
|
||||
export const elevatorResizeAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
start({ node, payload, initialPlanPoint }) {
|
||||
const { axis, side } = payload as ElevatorResizePayload
|
||||
const elevatorId = node.id as AnyNodeId
|
||||
const initialValue = axis === 'x' ? node.width : node.depth
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
const rotation = node.rotation ?? 0
|
||||
const cos = Math.cos(rotation)
|
||||
const sin = Math.sin(rotation)
|
||||
// Inverse of the elevator's local→plan rotation. The plan-space
|
||||
// matrix is `[c, s; -s, c]`; its inverse is `[c, -s; s, c]`.
|
||||
const projectLocalAxis = (px: number, pz: number): number => {
|
||||
const lx = (px - cx) * cos - (pz - cz) * sin
|
||||
const ly = (px - cx) * sin + (pz - cz) * cos
|
||||
return axis === 'x' ? lx : ly
|
||||
}
|
||||
const initialLocal = projectLocalAxis(initialPlanPoint[0], initialPlanPoint[1])
|
||||
let lastValue = initialValue
|
||||
|
||||
return {
|
||||
affectedIds: [elevatorId],
|
||||
apply({ planPoint }) {
|
||||
const currentLocal = projectLocalAxis(planPoint[0], planPoint[1])
|
||||
// `side` is +1 for the +axis arrow, -1 for the -axis arrow.
|
||||
// Pointer delta along the arrow's outward direction grows the
|
||||
// full span 2× (centre anchor).
|
||||
const delta = (currentLocal - initialLocal) * side
|
||||
const newValue = Math.max(MIN_ELEVATOR_DIM, initialValue + 2 * delta)
|
||||
lastValue = newValue
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(elevatorId, axis === 'x' ? { width: newValue } : { depth: newValue })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(elevatorId, axis === 'x' ? { width: lastValue } : { depth: lastValue })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Elevator rotation drag (floor-plan). Sister to the 3D `arc-resize`
|
||||
* handle declared in `definition.ts`. Same `- delta` sign convention as
|
||||
* the 3D path so dragging the cursor in the same direction in both views
|
||||
* produces the same rotation. Writes directly to scene during the drag;
|
||||
* the registry dispatcher captures a snapshot first and re-applies the
|
||||
* single tracked update on pointer-up.
|
||||
*/
|
||||
export const elevatorRotateAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
start({ node, initialPlanPoint }) {
|
||||
const elevatorId = node.id as AnyNodeId
|
||||
const initialRotation = node.rotation ?? 0
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
|
||||
let lastRotation = initialRotation
|
||||
|
||||
return {
|
||||
affectedIds: [elevatorId],
|
||||
apply({ planPoint }) {
|
||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
||||
let delta = currentAngle - initialAngle
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
const newRotation = initialRotation - delta
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(elevatorId, { rotation: newRotation })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(elevatorId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -10,33 +10,26 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan emitter for elevator. Renders:
|
||||
* Stage C floor-plan emitter for elevator. Architectural symbol style:
|
||||
*
|
||||
* - **Outer shaft footprint** — rotated rectangle (cab + wall thickness).
|
||||
* - **Cab indicator** — inner rectangle showing the cab's position within
|
||||
* the shaft. Highlighted when `runtime.currentLevelId` matches the
|
||||
* active level (i.e. the car is *on this floor*).
|
||||
* - **Door opening indicator** — a short marker on the front face
|
||||
* spanning `doorWidth` so users can see which way the doors open.
|
||||
* - **Selection / target / queued chrome** — selection stroke when
|
||||
* the elevator is selected, accent stroke when the runtime targets
|
||||
* this level (cab is travelling here) or this level is queued.
|
||||
* - **Outer shaft outline** — outer face of the shaft wall.
|
||||
* - **Inner shaft outline** — inner face of the shaft wall, offset
|
||||
* inward by `shaftWallThickness`. The two outlines together read as
|
||||
* a hollow wall in plan.
|
||||
* - **Dashed X** — two diagonals across the shaft interior, the
|
||||
* universal architectural mark for an elevator cab.
|
||||
* - **Door opening** — two small jamb stubs flanking the opening at
|
||||
* the front face, with a dashed line spanning the opening (the
|
||||
* closed door).
|
||||
* - **Selection / runtime chrome** — selected → palette stroke;
|
||||
* cab-on-level → green tint on the X; target/queued → sky accent.
|
||||
*
|
||||
* Reads the elevator's live state via `useLiveNodeOverrides.getState()`
|
||||
* (inspector edits) and `useInteractive.getState().elevators[id]`
|
||||
* (runtime cab travel). Those reads are non-reactive on their own —
|
||||
* `FloorplanRegistryLayer` subscribes to both stores so the layer
|
||||
* re-renders when they change, propagating into this builder.
|
||||
*
|
||||
* Per-level served-level chips (the small floor-label badges on each
|
||||
* shaft side) are not emitted yet — they need an HTML-overlay primitive
|
||||
* in `FloorplanGeometry` to render properly (SVG `<text>` rotates with
|
||||
* the plan, which mangles label legibility). Tracked as follow-up; the
|
||||
* legacy `<FloorplanElevatorLayer>` still renders the chips for
|
||||
* pre-registry builds while we figure out the right primitive shape.
|
||||
* Reads live overrides (`useLiveNodeOverrides`) and runtime cab state
|
||||
* (`useInteractive.elevators[id]`) non-reactively; the registry layer
|
||||
* subscribes to both stores so this builder re-runs on change.
|
||||
*/
|
||||
|
||||
const STAGE_LEVEL_FILTER_HIDE = true
|
||||
const STAGE_LEVEL_FILTER_HIDE = false
|
||||
|
||||
export function buildElevatorFloorplan(
|
||||
node: ElevatorNode,
|
||||
@@ -65,48 +58,33 @@ export function buildElevatorFloorplan(
|
||||
const cabDepth = Math.max(display.depth, 0.8)
|
||||
const shaftWidth = Math.max(display.shaftWidth ?? display.width, cabWidth, 0.8)
|
||||
const shaftDepth = Math.max(display.shaftDepth ?? display.depth, cabDepth, 0.8)
|
||||
const doorWidth = Math.min(Math.max(display.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18)
|
||||
const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness)
|
||||
const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness)
|
||||
const doorWidth = Math.min(Math.max(display.doorWidth, 0.45), shaftWidth - 0.24)
|
||||
const outerHalfW = Math.max(0.1, shaftWidth / 2 + wallThickness)
|
||||
const outerHalfD = Math.max(0.1, shaftDepth / 2 + wallThickness)
|
||||
const innerHalfW = Math.max(0.05, shaftWidth / 2)
|
||||
const innerHalfD = Math.max(0.05, shaftDepth / 2)
|
||||
|
||||
const center = { x: display.position[0], y: display.position[2] }
|
||||
const cos = Math.cos(display.rotation)
|
||||
const sin = Math.sin(display.rotation)
|
||||
const rotate = (lx: number, ly: number): [number, number] => {
|
||||
// Same clockwise convention as `rotatePlanVector` in editor — see
|
||||
// `wiki/architecture/tools.md` for why every plan-space rotation
|
||||
// uses this matrix and not the standard counter-clockwise one.
|
||||
// Negated-rotation matrix (equivalent to rotating by `-rotation`)
|
||||
// so SVG's CW-with-y-down `rotate` direction visually matches
|
||||
// Three.js Y-rotation (CCW from a top-down view). Column / shelf /
|
||||
// roof-segment do the same thing explicitly via `-node.rotation`
|
||||
// passed to `rotatePlanVector`; this is the inline version.
|
||||
return [lx * cos + ly * sin, -lx * sin + ly * cos]
|
||||
}
|
||||
|
||||
// Outer shaft footprint corners.
|
||||
const outerCorners: Array<readonly [number, number]> = [
|
||||
[-halfWidth, -halfDepth],
|
||||
[halfWidth, -halfDepth],
|
||||
[halfWidth, halfDepth],
|
||||
[-halfWidth, halfDepth],
|
||||
]
|
||||
const outerPoints: FloorplanPoint[] = outerCorners.map(([lx, ly]) => {
|
||||
const toPlan = (lx: number, ly: number): FloorplanPoint => {
|
||||
const [rx, ry] = rotate(lx, ly)
|
||||
return [center.x + rx, center.y + ry]
|
||||
})
|
||||
|
||||
// Cab inner rectangle. The cab sits flush against the front face
|
||||
// (-Z in local coords) so its center is `-shaftDepth/2 + cabDepth/2`
|
||||
// away from shaft center.
|
||||
const cabCenterLocalY = -shaftDepth / 2 + cabDepth / 2
|
||||
const cabHalfW = cabWidth / 2
|
||||
const cabHalfD = cabDepth / 2
|
||||
const cabCorners: Array<readonly [number, number]> = [
|
||||
[-cabHalfW, cabCenterLocalY - cabHalfD],
|
||||
[cabHalfW, cabCenterLocalY - cabHalfD],
|
||||
[cabHalfW, cabCenterLocalY + cabHalfD],
|
||||
[-cabHalfW, cabCenterLocalY + cabHalfD],
|
||||
}
|
||||
const rectPoints = (halfW: number, halfD: number): FloorplanPoint[] => [
|
||||
toPlan(-halfW, -halfD),
|
||||
toPlan(halfW, -halfD),
|
||||
toPlan(halfW, halfD),
|
||||
toPlan(-halfW, halfD),
|
||||
]
|
||||
const cabPoints: FloorplanPoint[] = cabCorners.map(([lx, ly]) => {
|
||||
const [rx, ry] = rotate(lx, ly)
|
||||
return [center.x + rx, center.y + ry]
|
||||
})
|
||||
|
||||
// Runtime state — current level / target level / queued.
|
||||
const runtime = useInteractive.getState().elevators[node.id]
|
||||
@@ -122,63 +100,120 @@ export function buildElevatorFloorplan(
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
// Stroke selection — selected wins, then runtime target / queued
|
||||
// states get the accent palette colour so users can spot "the cab is
|
||||
// coming here" at a glance.
|
||||
const stroke =
|
||||
showSelectedChrome && palette
|
||||
? palette.selectedStroke
|
||||
: isTargetLevel || isQueuedLevel
|
||||
? '#0ea5e9'
|
||||
: '#475569'
|
||||
// Shaft fill — orange when selected, light slate otherwise. When the
|
||||
// car is *on this level*, the cab indicator inside gets the highlight
|
||||
// instead of the whole shaft (more legible).
|
||||
const shaftFill = showSelectedChrome ? '#fed7aa' : '#cbd5e1'
|
||||
const cabFill = isCarOnLevel ? '#22c55e' : showSelectedChrome ? '#fef3c7' : '#e2e8f0'
|
||||
const cabStroke = isCarOnLevel ? '#15803d' : '#475569'
|
||||
// Base ink: near-black architectural outline. Selection switches to
|
||||
// the palette accent. Runtime state (car-on-level, target, queued)
|
||||
// is conveyed via the served-level chip column when selected, not
|
||||
// the main outline — so the floor-plan symbol stays neutral and
|
||||
// matches the line weight of the other architectural elements.
|
||||
const baseInk = '#111111'
|
||||
const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseInk
|
||||
const cabMarkInk = stroke
|
||||
|
||||
const children: FloorplanGeometry[] = []
|
||||
|
||||
// Outer shaft.
|
||||
// Invisible hit-target — closed polygon over the full outer
|
||||
// footprint so clicks anywhere inside the elevator (not just on the
|
||||
// stroked outlines) select the node. The outer outline below is a
|
||||
// polyline with `fill='none'`, so without this layer the interior
|
||||
// would fall through to whatever sits beneath.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: outerPoints,
|
||||
fill: shaftFill,
|
||||
points: rectPoints(outerHalfW, outerHalfD),
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
})
|
||||
|
||||
// Door geometry. The jambs are little U-shaped notches that hang
|
||||
// BELOW the outer wall's bottom edge on either side of the door
|
||||
// opening; the outer outline traces around them and breaks for the
|
||||
// door in the middle.
|
||||
const doorY = -outerHalfD
|
||||
const effectiveDoorWidth = Math.min(doorWidth, innerHalfW * 2 - 0.16)
|
||||
const jambInnerX = effectiveDoorWidth / 2
|
||||
const jambWidth = Math.max(0.08, Math.min(wallThickness * 2.2, (outerHalfW - jambInnerX) * 0.55))
|
||||
const jambOuterX = Math.min(jambInnerX + jambWidth, outerHalfW - 0.04)
|
||||
const jambStubDepth = Math.max(0.06, wallThickness * 1.05)
|
||||
|
||||
// Outer outline — single polyline that traces clockwise from the
|
||||
// left edge of the door opening, around the left jamb stub, along
|
||||
// the bottom-left segment, up the left side, across the top, down
|
||||
// the right side, along the bottom-right segment, around the right
|
||||
// jamb stub, ending at the right edge of the door opening. The two
|
||||
// endpoints frame the door gap; SVG `polyline` doesn't close.
|
||||
const outerOutline: FloorplanPoint[] = [
|
||||
toPlan(-jambInnerX, doorY),
|
||||
toPlan(-jambInnerX, doorY - jambStubDepth),
|
||||
toPlan(-jambOuterX, doorY - jambStubDepth),
|
||||
toPlan(-jambOuterX, doorY),
|
||||
toPlan(-outerHalfW, doorY),
|
||||
toPlan(-outerHalfW, outerHalfD),
|
||||
toPlan(outerHalfW, outerHalfD),
|
||||
toPlan(outerHalfW, doorY),
|
||||
toPlan(jambOuterX, doorY),
|
||||
toPlan(jambOuterX, doorY - jambStubDepth),
|
||||
toPlan(jambInnerX, doorY - jambStubDepth),
|
||||
toPlan(jambInnerX, doorY),
|
||||
]
|
||||
children.push({
|
||||
kind: 'polyline',
|
||||
points: outerOutline,
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.04 : 0.03,
|
||||
strokeLinejoin: 'round',
|
||||
opacity: 0.85,
|
||||
strokeWidth: showSelectedChrome ? 0.035 : 0.025,
|
||||
strokeLinejoin: 'miter',
|
||||
strokeLinecap: 'butt',
|
||||
})
|
||||
|
||||
// Cab inner rectangle.
|
||||
// Inner outline — closed rectangle, the inner face of the shaft.
|
||||
// The X diagonals terminate at its corners.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: cabPoints,
|
||||
fill: cabFill,
|
||||
fillOpacity: isCarOnLevel ? 0.85 : 0.55,
|
||||
stroke: cabStroke,
|
||||
strokeWidth: 0.018,
|
||||
strokeLinejoin: 'round',
|
||||
opacity: 0.92,
|
||||
points: rectPoints(innerHalfW, innerHalfD),
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: 0.02,
|
||||
strokeLinejoin: 'miter',
|
||||
})
|
||||
|
||||
// Door opening indicator — a short line on the front edge centered
|
||||
// on the cab. The legacy renders a more complex slide / center-open
|
||||
// hint; this is the minimum useful signal.
|
||||
const doorY = -halfDepth
|
||||
const [doorStartX, doorStartY] = rotate(-doorWidth / 2, doorY)
|
||||
const [doorEndX, doorEndY] = rotate(doorWidth / 2, doorY)
|
||||
// Dashed X across the shaft interior — corner-to-corner of the
|
||||
// inner rectangle, the universal elevator-cab mark.
|
||||
const diagonals: Array<readonly [FloorplanPoint, FloorplanPoint]> = [
|
||||
[toPlan(-innerHalfW, -innerHalfD), toPlan(innerHalfW, innerHalfD)],
|
||||
[toPlan(innerHalfW, -innerHalfD), toPlan(-innerHalfW, innerHalfD)],
|
||||
]
|
||||
for (const [start, end] of diagonals) {
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: start[0],
|
||||
y1: start[1],
|
||||
x2: end[0],
|
||||
y2: end[1],
|
||||
stroke: cabMarkInk,
|
||||
strokeWidth: 0.018,
|
||||
strokeDasharray: '0.08 0.06',
|
||||
strokeLinecap: 'butt',
|
||||
opacity: 0.85,
|
||||
})
|
||||
}
|
||||
|
||||
// Dashed door line — sits on the outer wall line, spanning the gap
|
||||
// between the two jamb stubs.
|
||||
const doorStart = toPlan(-jambInnerX, doorY)
|
||||
const doorEnd = toPlan(jambInnerX, doorY)
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: center.x + doorStartX,
|
||||
y1: center.y + doorStartY,
|
||||
x2: center.x + doorEndX,
|
||||
y2: center.y + doorEndY,
|
||||
stroke: isCarOnLevel ? '#15803d' : '#0f172a',
|
||||
strokeWidth: 0.05,
|
||||
strokeLinecap: 'round',
|
||||
opacity: 0.92,
|
||||
x1: doorStart[0],
|
||||
y1: doorStart[1],
|
||||
x2: doorEnd[0],
|
||||
y2: doorEnd[1],
|
||||
stroke: cabMarkInk,
|
||||
strokeWidth: 0.02,
|
||||
strokeDasharray: '0.08 0.06',
|
||||
strokeLinecap: 'butt',
|
||||
opacity: 0.9,
|
||||
})
|
||||
|
||||
// Served-level chips — vertical column of marker circles + level
|
||||
@@ -194,7 +229,7 @@ export function buildElevatorFloorplan(
|
||||
const serviceOnlyLevelIds = new Set(display.serviceOnlyLevelIds ?? [])
|
||||
const rangeStep = 0.18
|
||||
const rangeHeight = Math.max(0, (serviceLevelIds.length - 1) * rangeStep)
|
||||
const [rangeOffsetX, rangeOffsetY] = rotate(halfWidth + 0.38, 0)
|
||||
const [rangeOffsetX, rangeOffsetY] = rotate(outerHalfW + 0.38, 0)
|
||||
const rangeX = center.x + rangeOffsetX
|
||||
const rangeBottomY = center.y + rangeOffsetY + rangeHeight / 2
|
||||
const rangeTopY = center.y + rangeOffsetY - rangeHeight / 2
|
||||
@@ -265,11 +300,59 @@ export function buildElevatorFloorplan(
|
||||
}
|
||||
}
|
||||
|
||||
// Selection chrome — orange move-handle dot at the centre, four
|
||||
// perpendicular side resize-arrows ringing the outer shaft, and a
|
||||
// rotate-arrow at the front-right corner. Side arrows drive `width`
|
||||
// (X-axis) and `depth` (Z-axis) through `elevatorResizeAffordance`
|
||||
// with `anchor: 'center'` — sister of the 3D `linear-resize` handles
|
||||
// in `definition.ts`. Body move is reached via the centroid dot or
|
||||
// the floating action menu's Move button.
|
||||
if (isSelected) {
|
||||
children.push({
|
||||
kind: 'move-handle',
|
||||
point: [display.position[0], display.position[2]],
|
||||
})
|
||||
|
||||
const sideArrowOffset = 0.12
|
||||
const rotateCornerOffset = 0.22
|
||||
const cx = display.position[0]
|
||||
const cz = display.position[2]
|
||||
const sides: Array<{
|
||||
local: [number, number]
|
||||
localAngle: number
|
||||
axis: 'x' | 'z'
|
||||
side: 1 | -1
|
||||
}> = [
|
||||
{ local: [outerHalfW + sideArrowOffset, 0], localAngle: 0, axis: 'x', side: 1 },
|
||||
{ local: [-(outerHalfW + sideArrowOffset), 0], localAngle: Math.PI, axis: 'x', side: -1 },
|
||||
{ local: [0, outerHalfD + sideArrowOffset], localAngle: Math.PI / 2, axis: 'z', side: 1 },
|
||||
{ local: [0, -(outerHalfD + sideArrowOffset)], localAngle: -Math.PI / 2, axis: 'z', side: -1 },
|
||||
]
|
||||
for (const side of sides) {
|
||||
const [ox, oz] = rotate(side.local[0], side.local[1])
|
||||
const [tx, tz] = rotate(Math.cos(side.localAngle), Math.sin(side.localAngle))
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [cx + ox, cz + oz],
|
||||
angle: Math.atan2(tz, tx),
|
||||
affordance: 'elevator-resize',
|
||||
payload: { axis: side.axis, side: side.side },
|
||||
})
|
||||
}
|
||||
|
||||
// Rotate-arrow at the +X / +Z corner. `localAngle = π/4` puts the
|
||||
// curved arrow's bow at the diagonal corner so it reads as a
|
||||
// rotation gizmo around the elevator centre.
|
||||
const cornerLocalX = outerHalfW + rotateCornerOffset
|
||||
const cornerLocalZ = outerHalfD + rotateCornerOffset
|
||||
const [cornerX, cornerZ] = rotate(cornerLocalX, cornerLocalZ)
|
||||
const [radialX, radialZ] = rotate(1, 1)
|
||||
children.push({
|
||||
kind: 'rotate-arrow',
|
||||
point: [cx + cornerX, cz + cornerZ],
|
||||
angle: Math.atan2(radialZ, radialX),
|
||||
affordance: 'elevator-rotate',
|
||||
})
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pascal-app/editor'
|
||||
import {
|
||||
type FencePlanPoint,
|
||||
isSegmentLongEnough,
|
||||
snapFenceDraftPoint,
|
||||
WALL_FINE_GRID_STEP,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — move-fence-endpoint drag affordance.
|
||||
@@ -23,7 +28,7 @@ import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pas
|
||||
* - **apply**: writes the fence + linked fence endpoints into the
|
||||
* scene. Drag-session paused history captures originals; cascade
|
||||
* resolver fans dirty marks through `endpoint-match`.
|
||||
* - **commit**: requires `hasChanged` && `isWallLongEnough(next)`.
|
||||
* - **commit**: requires `hasChanged` && `isSegmentLongEnough(next)`.
|
||||
* Performs the single-undo dance — revert to originals (snapshot),
|
||||
* resume history, re-apply final draft — so the entire drag is one
|
||||
* `Ctrl-Z` step. Returns false to reject; `createDragSession.cancel`
|
||||
@@ -143,13 +148,15 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
|
||||
preview: (ctx, point, modifiers) => {
|
||||
const planPoint: FencePlanPoint = [point[0], point[1]]
|
||||
// Endpoint move = grid snap only; the 45°-from-start angle snap
|
||||
// is draft-only. Shift switches to the fine grid step for
|
||||
// precision, mirroring the wall convention.
|
||||
const snapped = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls: ctx.levelWalls,
|
||||
fences: ctx.levelFences,
|
||||
start: ctx.fixedPoint,
|
||||
angleSnap: !modifiers.shift,
|
||||
ignoreFenceIds: [ctx.fenceId as string],
|
||||
step: modifiers.shift ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
const nextStart = ctx.endpoint === 'start' ? snapped : ctx.fixedPoint
|
||||
const nextEnd = ctx.endpoint === 'end' ? snapped : ctx.fixedPoint
|
||||
@@ -189,7 +196,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
// fence/actions/curve.ts for the rationale (no-op drag must still
|
||||
// push a pastState entry to avoid Ctrl-Z cancelling the fence
|
||||
// creation that preceded the activation).
|
||||
if (!isWallLongEnough(draft.start, draft.end)) return false
|
||||
if (!isSegmentLongEnough(draft.start, draft.end)) return false
|
||||
|
||||
// Single-undo dance: revert to originals (paused history → no
|
||||
// zundo record), resume history, then re-apply the final draft
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
getWallGridStep,
|
||||
getSegmentGridStep,
|
||||
markToolCancelConsumed,
|
||||
snapScalarToGrid,
|
||||
triggerSFX,
|
||||
@@ -32,7 +32,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
* fence/curve-fence-tool.tsx). Same snap pipeline, same Shift override,
|
||||
* same history dance, same activation grace. Imports adjusted to the
|
||||
* `@pascal-app/editor` public surface (triggerSFX, markToolCancelConsumed,
|
||||
* getWallGridStep, snapScalarToGrid). Mounted via
|
||||
* getSegmentGridStep, snapScalarToGrid). Mounted via
|
||||
* `def.affordanceTools.curve` — ToolManager picks it up at runtime,
|
||||
* legacy fallback is unused when this kind is registered.
|
||||
*/
|
||||
@@ -89,7 +89,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const snapStep = getWallGridStep()
|
||||
const snapStep = getSegmentGridStep()
|
||||
const localX = shiftPressedRef.current
|
||||
? event.localPosition[0]
|
||||
: snapScalarToGrid(event.localPosition[0], snapStep)
|
||||
|
||||
@@ -1,10 +1,123 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import {
|
||||
type FenceNode as FenceNodeType,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { buildFenceFloorplan } from './floorplan'
|
||||
import { fenceMoveEndpointAffordance } from './floorplan-affordances'
|
||||
import { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances'
|
||||
import { fenceFloorplanMoveTarget } from './floorplan-move'
|
||||
import { buildFenceGeometry } from './geometry'
|
||||
import { fenceParametrics } from './parametrics'
|
||||
import { FenceNode } from './schema'
|
||||
|
||||
const SIDE_HANDLE_OFFSET = 0.27
|
||||
const SIDE_HANDLE_MIN_OFFSET = 0.33
|
||||
const SIDE_HANDLE_TOP_INSET = 0.08
|
||||
const SIDE_HANDLE_MIN_HEIGHT = 0.4
|
||||
const HEIGHT_HANDLE_OFFSET = 0.45
|
||||
const MIN_FENCE_HEIGHT = 0.3
|
||||
|
||||
function fenceMidpointFrame(n: FenceNodeType): {
|
||||
midX: number
|
||||
midZ: number
|
||||
normalX: number
|
||||
normalZ: number
|
||||
} {
|
||||
const dx = n.end[0] - n.start[0]
|
||||
const dz = n.end[1] - n.start[1]
|
||||
const len = Math.max(Math.hypot(dx, dz), 1e-6)
|
||||
return {
|
||||
midX: (n.start[0] + n.end[0]) / 2,
|
||||
midZ: (n.start[1] + n.end[1]) / 2,
|
||||
normalX: -dz / len,
|
||||
normalZ: dx / len,
|
||||
}
|
||||
}
|
||||
|
||||
// Side-move arrows: click to hand the fence to its move tool. Same shape
|
||||
// as wall — front + back faces, positioned past the fence thickness near
|
||||
// the top so they don't compete with endpoint pickers in the floating
|
||||
// menu (which is where fence endpoint move lives today).
|
||||
function fenceSideMoveHandle(side: 'front' | 'back'): HandleDescriptor<FenceNodeType> {
|
||||
const sign = side === 'front' ? 1 : -1
|
||||
return {
|
||||
kind: 'tap-action',
|
||||
onActivate: (node, _scene, editor) => editor.engageMove(node),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const { midX, midZ, normalX, normalZ } = fenceMidpointFrame(n)
|
||||
const offset = Math.max(
|
||||
(n.thickness ?? 0.1) / 2 + SIDE_HANDLE_OFFSET,
|
||||
SIDE_HANDLE_MIN_OFFSET,
|
||||
)
|
||||
const h = n.height ?? 1.8
|
||||
const handleY = Math.max(h - SIDE_HANDLE_TOP_INSET, SIDE_HANDLE_MIN_HEIGHT)
|
||||
return [midX + sign * normalX * offset, handleY, midZ + sign * normalZ * offset]
|
||||
},
|
||||
rotationY: (n) => {
|
||||
const { normalX, normalZ } = fenceMidpointFrame(n)
|
||||
return Math.atan2(-sign * normalZ, sign * normalX)
|
||||
},
|
||||
},
|
||||
cursor: 'move',
|
||||
}
|
||||
}
|
||||
|
||||
// Height arrow — anchored at the floor (Y=0), grows upward. Sits over
|
||||
// the fence midpoint at the top edge with enough clearance to clear the
|
||||
// side-move arrows that hug the rail. `rotationY` orients the chevron's
|
||||
// broad face along the fence's perpendicular (same direction the front
|
||||
// side-move arrow points), so the chevron reads frontally when viewing
|
||||
// the fence from either side rather than going edge-on.
|
||||
function fenceHeightHandle(): HandleDescriptor<FenceNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_FENCE_HEIGHT,
|
||||
currentValue: (n) => n.height ?? 1.8,
|
||||
apply: (_n, newHeight) => ({ height: newHeight }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const { midX, midZ } = fenceMidpointFrame(n)
|
||||
return [midX, (n.height ?? 1.8) + HEIGHT_HANDLE_OFFSET, midZ]
|
||||
},
|
||||
rotationY: (n) => {
|
||||
const { normalX, normalZ } = fenceMidpointFrame(n)
|
||||
return Math.atan2(-normalZ, normalX)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Corner picker — dashed vertical leader + billboarded hex disc at the
|
||||
// endpoint. Tap engages the endpoint-move flow (sister to the wall
|
||||
// pickers). nodeHeight controls the leader's vertical reach so the
|
||||
// dashes span the full fence height.
|
||||
function fenceCornerPicker(endpoint: 'start' | 'end'): HandleDescriptor<FenceNodeType> {
|
||||
return {
|
||||
kind: 'tap-action',
|
||||
shape: 'corner-picker',
|
||||
cursor: 'move',
|
||||
nodeHeight: (n) => n.height ?? 1.8,
|
||||
onActivate: (node, _scene, editor) => editor.engageEndpointMove(node, endpoint),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const corner = endpoint === 'start' ? n.start : n.end
|
||||
return [corner[0], 0, corner[1]]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fenceHandles: HandleDescriptor<FenceNodeType>[] = [
|
||||
fenceSideMoveHandle('front'),
|
||||
fenceSideMoveHandle('back'),
|
||||
fenceHeightHandle(),
|
||||
fenceCornerPicker('start'),
|
||||
fenceCornerPicker('end'),
|
||||
]
|
||||
|
||||
/**
|
||||
* Fence — Phase 5 batch kind. Stage B complete: `def.geometry` drives
|
||||
* the rebuild via the generic `<GeometrySystem>`; `<ParametricNodeRenderer>`
|
||||
@@ -59,6 +172,7 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
||||
},
|
||||
|
||||
parametrics: fenceParametrics,
|
||||
handles: fenceHandles,
|
||||
|
||||
// Stage D: kind-owned placement tool. Two-click flow (start → end)
|
||||
// with live preview, length / angle HUD, snap to walls / fences /
|
||||
@@ -76,13 +190,23 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
||||
// Legacy `floorplanFenceEntries` short-circuits to [] when fence is
|
||||
// registered (see floorplan-panel.tsx).
|
||||
floorplan: buildFenceFloorplan,
|
||||
// 2D drag affordance — sister to `actions/move-endpoint.ts`. The 3D
|
||||
// DragAction drives R3F grid events through `createDragSession`; this
|
||||
// one drives SVG pointer events through the floor-plan registry
|
||||
// dispatcher's snapshot + single-undo dance. Same legacy semantics.
|
||||
// 2D drag affordances — sister to `actions/move-endpoint.ts`. The 3D
|
||||
// DragAction drives R3F grid events through `createDragSession`; these
|
||||
// drive SVG pointer events through the floor-plan registry
|
||||
// dispatcher's snapshot + single-undo dance. `move-endpoint` keeps the
|
||||
// legacy fence endpoint semantics; `curve` mirrors the wall sagitta
|
||||
// drag (publishes `curveOffset` overrides per tick, commits on
|
||||
// pointer-up).
|
||||
floorplanAffordances: {
|
||||
'move-endpoint': fenceMoveEndpointAffordance,
|
||||
curve: fenceCurveAffordance,
|
||||
},
|
||||
// Body move on the fence is driven by the two `move-arrow` chevrons
|
||||
// the floor-plan builder emits at the midpoint. Pointer-down enters
|
||||
// movingNode mode; the registry overlay routes through this target
|
||||
// for the live preview + commit. Translates the dragged fence and
|
||||
// cascades the shared endpoints of any linked fences, ALT detaches.
|
||||
floorplanMoveTarget: fenceFloorplanMoveTarget,
|
||||
// Stage D — all four fence drag-affordances live in this folder.
|
||||
// curve / move-endpoint / move are 1:1 ports of the legacy tools
|
||||
// (same snap pipeline, same history dance, same cursor render),
|
||||
|
||||
@@ -4,10 +4,21 @@ import {
|
||||
type FenceNode,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
normalizeWallCurveOffset,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pascal-app/editor'
|
||||
import {
|
||||
type FencePlanPoint,
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
snapFenceDraftPoint,
|
||||
snapScalarToGrid,
|
||||
WALL_FINE_GRID_STEP,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Floor-plan 2D drag affordances for fence — sister to the 3D
|
||||
@@ -70,6 +81,57 @@ function collectLinkedFences(
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Fence curve sagitta drag — 1:1 mirror of `wallCurveAffordance`. Drag
|
||||
* projects the pointer onto the chord normal to compute `curveOffset`,
|
||||
* snaps to grid (Shift bypasses), clamps to `getMaxWallCurveOffset`,
|
||||
* normalizes via `normalizeWallCurveOffset`. Same single-undo dance — the
|
||||
* dispatcher handles snapshot / pause / resume around `apply`. Lives in
|
||||
* the same file as the endpoint affordance to keep the two fence
|
||||
* floor-plan drags side-by-side (both publish to `useLiveNodeOverrides`,
|
||||
* both committed on pointer-up).
|
||||
*/
|
||||
export const fenceCurveAffordance: FloorplanAffordance<FenceNode> = {
|
||||
start({ node }): FloorplanAffordanceSession {
|
||||
const chord = getWallChordFrame(node)
|
||||
const maxOffset = getMaxWallCurveOffset(node)
|
||||
const fenceId = node.id as AnyNodeId
|
||||
let lastCurveOffset = node.curveOffset ?? 0
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapStep = getSegmentGridStep()
|
||||
const x = modifiers.shiftKey ? planPoint[0] : snapScalarToGrid(planPoint[0], snapStep)
|
||||
const y = modifiers.shiftKey ? planPoint[1] : snapScalarToGrid(planPoint[1], snapStep)
|
||||
|
||||
const offsetFromMidpoint = -(
|
||||
(x - chord.midpoint.x) * chord.normal.x +
|
||||
(y - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
const snappedOffset = modifiers.shiftKey
|
||||
? offsetFromMidpoint
|
||||
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(
|
||||
node,
|
||||
Math.max(-maxOffset, Math.min(maxOffset, snappedOffset)),
|
||||
)
|
||||
lastCurveOffset = nextCurveOffset
|
||||
|
||||
useLiveNodeOverrides.getState().set(fenceId, { curveOffset: nextCurveOffset })
|
||||
useScene.getState().markDirty(fenceId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNodes([{ id: fenceId, data: { curveOffset: lastCurveOffset } }])
|
||||
useLiveNodeOverrides.getState().clear(fenceId)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
start({ node, payload, nodes }): FloorplanAffordanceSession {
|
||||
const { endpoint } = payload as FenceEndpointPayload
|
||||
@@ -92,13 +154,15 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
// itself is excluded via `ignoreFenceIds`).
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId)
|
||||
// Endpoint move = grid snap only; the 45°-from-start angle
|
||||
// snap is draft-only. Shift switches to the fine grid step for
|
||||
// precision, matching the 3D fence endpoint action.
|
||||
const snapped = snapFenceDraftPoint({
|
||||
point: planPoint as FencePlanPoint,
|
||||
walls: nextWalls,
|
||||
fences: nextFences,
|
||||
start: fixedPoint,
|
||||
angleSnap: !modifiers.shiftKey,
|
||||
ignoreFenceIds: [node.id],
|
||||
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
const nextStart = endpoint === 'start' ? snapped : fixedPoint
|
||||
const nextEnd = endpoint === 'end' ? snapped : fixedPoint
|
||||
@@ -124,7 +188,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
return (
|
||||
!!finalFence &&
|
||||
finalFence.type === 'fence' &&
|
||||
isWallLongEnough(finalFence.start, finalFence.end)
|
||||
isSegmentLongEnough(finalFence.start, finalFence.end)
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FenceNode,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getSegmentGridStep, isSegmentLongEnough, snapPointToGrid } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
|
||||
type PlanPoint = [number, number]
|
||||
|
||||
function pointsEqual(a: PlanPoint, b: PlanPoint): boolean {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
type LinkedFenceSnapshot = { id: AnyNodeId; start: PlanPoint; end: PlanPoint }
|
||||
|
||||
function getLinkedFenceSnapshots(args: {
|
||||
fenceId: AnyNodeId
|
||||
parentId: string | null
|
||||
originalStart: PlanPoint
|
||||
originalEnd: PlanPoint
|
||||
}): LinkedFenceSnapshot[] {
|
||||
const { fenceId, parentId, originalStart, originalEnd } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const snapshots: LinkedFenceSnapshot[] = []
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node?.type !== 'fence' || node.id === fenceId) continue
|
||||
if ((node.parentId ?? null) !== parentId) continue
|
||||
const fence = node as FenceNode
|
||||
if (
|
||||
pointsEqual(fence.start as PlanPoint, originalStart) ||
|
||||
pointsEqual(fence.start as PlanPoint, originalEnd) ||
|
||||
pointsEqual(fence.end as PlanPoint, originalStart) ||
|
||||
pointsEqual(fence.end as PlanPoint, originalEnd)
|
||||
) {
|
||||
snapshots.push({
|
||||
id: fence.id as AnyNodeId,
|
||||
start: [fence.start[0], fence.start[1]],
|
||||
end: [fence.end[0], fence.end[1]],
|
||||
})
|
||||
}
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D floor-plan body move for fence. Mirrors `wallFloorplanMoveTarget`
|
||||
* but without bridge-wall planning: fence corners cascade through
|
||||
* shared endpoints, ALT detaches them, and there's no perpendicular
|
||||
* branch logic to chase. Tick publishes endpoint overrides; commit
|
||||
* folds them into a single tracked update.
|
||||
*/
|
||||
export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node }) => {
|
||||
const fenceId = node.id as AnyNodeId
|
||||
const originalStart: PlanPoint = [node.start[0], node.start[1]]
|
||||
const originalEnd: PlanPoint = [node.end[0], node.end[1]]
|
||||
const originalMetadata =
|
||||
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!originalMetadata.isNew
|
||||
|
||||
const linkedOriginals: LinkedFenceSnapshot[] = isNew
|
||||
? []
|
||||
: getLinkedFenceSnapshots({
|
||||
fenceId,
|
||||
parentId: node.parentId ?? null,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
})
|
||||
|
||||
let rawAnchor: PlanPoint | null = null
|
||||
let lastDelta: PlanPoint = [0, 0]
|
||||
let lastNextStart: PlanPoint = originalStart
|
||||
let lastNextEnd: PlanPoint = originalEnd
|
||||
|
||||
const projectLinked = (
|
||||
snapshot: LinkedFenceSnapshot,
|
||||
nextStart: PlanPoint,
|
||||
nextEnd: PlanPoint,
|
||||
): { start: PlanPoint; end: PlanPoint } => ({
|
||||
start: pointsEqual(snapshot.start, originalStart)
|
||||
? nextStart
|
||||
: pointsEqual(snapshot.start, originalEnd)
|
||||
? nextEnd
|
||||
: snapshot.start,
|
||||
end: pointsEqual(snapshot.end, originalStart)
|
||||
? nextStart
|
||||
: pointsEqual(snapshot.end, originalEnd)
|
||||
? nextEnd
|
||||
: snapshot.end,
|
||||
})
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [fenceId, ...linkedOriginals.map((l) => l.id)],
|
||||
|
||||
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()
|
||||
const nextStart: PlanPoint = modifiers.shiftKey
|
||||
? [originalStart[0] + rawDx, originalStart[1] + rawDz]
|
||||
: snapPointToGrid([originalStart[0] + rawDx, originalStart[1] + rawDz], step)
|
||||
const dx = nextStart[0] - originalStart[0]
|
||||
const dz = nextStart[1] - originalStart[1]
|
||||
if (dx === lastDelta[0] && dz === lastDelta[1]) return
|
||||
lastDelta = [dx, dz]
|
||||
const nextEnd: PlanPoint = [originalEnd[0] + dx, originalEnd[1] + dz]
|
||||
lastNextStart = nextStart
|
||||
lastNextEnd = nextEnd
|
||||
|
||||
const linkedUpdates = modifiers.altKey
|
||||
? []
|
||||
: linkedOriginals.map((l) => ({ id: l.id, ...projectLinked(l, nextStart, nextEnd) }))
|
||||
|
||||
useLiveNodeOverrides
|
||||
.getState()
|
||||
.setMany([
|
||||
[fenceId, { start: nextStart, end: nextEnd }],
|
||||
...linkedUpdates.map(
|
||||
(u) => [u.id, { start: u.start, end: u.end }] as [string, Record<string, unknown>],
|
||||
),
|
||||
])
|
||||
const sceneState = useScene.getState()
|
||||
sceneState.markDirty(fenceId)
|
||||
for (const u of linkedUpdates) sceneState.markDirty(u.id)
|
||||
},
|
||||
|
||||
canCommit() {
|
||||
const [dx, dz] = lastDelta
|
||||
return (dx !== 0 || dz !== 0) && isSegmentLongEnough(lastNextStart, lastNextEnd)
|
||||
},
|
||||
|
||||
commit() {
|
||||
// The overlay (see `floorplan-registry-move-overlay.tsx`) has already
|
||||
// (a) written the snapshot back to scene to establish a clean
|
||||
// baseline for the single-undo dance and (b) resumed history.
|
||||
// This `updateNodes` IS the final-state write — recorded as one
|
||||
// tracked change. Drop the override AFTER the scene write so
|
||||
// mid-commit reads still see the new position (override wins until
|
||||
// cleared; scene wins after).
|
||||
const fenceUpdate: { id: AnyNodeId; data: Partial<FenceNode> } = isNew
|
||||
? {
|
||||
id: fenceId,
|
||||
data: {
|
||||
start: lastNextStart,
|
||||
end: lastNextEnd,
|
||||
metadata: { ...originalMetadata, isNew: false },
|
||||
} as Partial<FenceNode>,
|
||||
}
|
||||
: { id: fenceId, data: { start: lastNextStart, end: lastNextEnd } }
|
||||
const linkedUpdates = linkedOriginals.map((l) => ({
|
||||
id: l.id,
|
||||
...projectLinked(l, lastNextStart, lastNextEnd),
|
||||
}))
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([
|
||||
fenceUpdate,
|
||||
...linkedUpdates.map((u) => ({ id: u.id, data: { start: u.start, end: u.end } })),
|
||||
])
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
overrides.clear(fenceId)
|
||||
for (const l of linkedOriginals) overrides.clear(l.id)
|
||||
// Re-select the moved fence so selection-gated chrome (endpoint
|
||||
// handles, side arrows, curve dot) remains visible at the new
|
||||
// position — the action menu's Move click cleared selection on
|
||||
// entry. Matches the wall move-target's post-commit re-select.
|
||||
useViewer.getState().setSelection({ selectedIds: [fenceId] })
|
||||
},
|
||||
}
|
||||
return session
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type GeometryContext,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
getWallMidpointHandlePoint,
|
||||
isCurvedWall,
|
||||
sampleWallCenterline,
|
||||
} from '@pascal-app/core'
|
||||
@@ -339,7 +340,10 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
|
||||
cursor: 'pointer',
|
||||
})
|
||||
|
||||
// 6. Endpoint handles + length label when selected.
|
||||
// 6. Endpoint handles + side move-arrows + curve handle + length when
|
||||
// selected. Mirrors the wall builder so fences gain the same set of
|
||||
// in-plan affordances (drag endpoints, drag body via either side
|
||||
// arrow, drag the midpoint sagitta to curve).
|
||||
if (isSelected) {
|
||||
children.push({
|
||||
kind: 'endpoint-handle',
|
||||
@@ -356,6 +360,50 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
|
||||
payload: { fenceId: node.id, endpoint: 'end' as const },
|
||||
})
|
||||
|
||||
// Two perpendicular `move-arrow` chevrons at the fence midpoint.
|
||||
// No `affordance` → the registry layer routes pointer-down through
|
||||
// `setMovingNode`, which the `FloorplanRegistryMoveOverlay` picks
|
||||
// up and runs through `def.floorplanMoveTarget` (see
|
||||
// `fence/floorplan-move.ts`). Sized in plan-units like the wall
|
||||
// counterpart so they shrink / grow with zoom.
|
||||
{
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
const lineLength = Math.hypot(dx, dz)
|
||||
if (lineLength > 1e-6) {
|
||||
const frame = isCurvedWall(node) ? getWallCurveFrameAt(node, 0.5) : null
|
||||
const midX = frame ? frame.point.x : (node.start[0] + node.end[0]) / 2
|
||||
const midZ = frame ? frame.point.y : (node.start[1] + node.end[1]) / 2
|
||||
const nx = frame ? frame.normal.x : -dz / lineLength
|
||||
const nz = frame ? frame.normal.y : dx / lineLength
|
||||
const offset = (node.thickness ?? 0.08) / 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 visual midpoint that drives
|
||||
// `curveOffset`. Routes through `fenceCurveAffordance`. Fences host
|
||||
// no children, so there's no equivalent of wall's curve-blocking
|
||||
// check to gate this.
|
||||
const curveHandle = getWallMidpointHandlePoint(node)
|
||||
children.push({
|
||||
kind: 'endpoint-handle',
|
||||
point: [curveHandle.x, curveHandle.y],
|
||||
state: 'idle',
|
||||
variant: 'curve',
|
||||
affordance: 'curve',
|
||||
payload: { fenceId: node.id },
|
||||
})
|
||||
|
||||
const length = getWallCurveLength(node)
|
||||
if (length >= 0.1) {
|
||||
const midX = (node.start[0] + node.end[0]) / 2
|
||||
|
||||
@@ -266,6 +266,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
useViewer.getState().setSelection({ selectedIds: [fenceId] })
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
// Claim teardown ownership so the 2D overlay doesn't redundantly
|
||||
// revert the same baseline on its own cleanup. Mirrors wall's
|
||||
// move-tool cancel path.
|
||||
useEditor.getState().setMovingNodeOrigin('3d')
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
@@ -275,7 +279,22 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal()
|
||||
// The 2D `FloorplanRegistryMoveOverlay` mounts in parallel with
|
||||
// this 3D tool whenever the user enters fence move mode from the
|
||||
// floor plan. When the 2D overlay commits via
|
||||
// `fenceFloorplanMoveTarget.commit()` it calls
|
||||
// `setMovingNode(null)`, which unmounts this tool. Our local
|
||||
// `wasCommitted` is still false (its own `onGridClick` never
|
||||
// ran), so a blind `restoreOriginal()` here would overwrite the
|
||||
// just-committed new positions back to the originals — the
|
||||
// "fence reverts on commit" symptom users see in the 2D view.
|
||||
// The 2D overlay sets `movingNodeOrigin = '2d'` before clearing
|
||||
// movingNode; respect that flag and skip the restore. Mirrors
|
||||
// the wall move-tool's `finalisedBy2D` guard.
|
||||
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
||||
if (!finalisedBy2D) {
|
||||
restoreOriginal()
|
||||
}
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type SegmentAngleReference,
|
||||
snapFenceDraftPoint,
|
||||
triggerSFX,
|
||||
WALL_FINE_GRID_STEP,
|
||||
} from '@pascal-app/editor'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
@@ -437,14 +438,16 @@ export const FenceTool: React.FC = () => {
|
||||
if (!(cursorRef.current && previewRef.current)) return
|
||||
const { walls, fences } = getCurrentLevelElements()
|
||||
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
// Default = active grid step; Shift switches to the fine step
|
||||
// (0.05m). No 45° angle snap — see `wall/tool.tsx` for rationale.
|
||||
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
|
||||
if (buildingState.current === 1) {
|
||||
const snappedLocal = snapFenceDraftPoint({
|
||||
point: localPoint,
|
||||
walls,
|
||||
fences,
|
||||
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)
|
||||
@@ -467,7 +470,7 @@ export const FenceTool: React.FC = () => {
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
|
||||
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences, step })
|
||||
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
||||
setDraftMeasurement(null)
|
||||
}
|
||||
@@ -481,9 +484,15 @@ export const FenceTool: React.FC = () => {
|
||||
|
||||
const { walls, fences } = getCurrentLevelElements()
|
||||
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = snapFenceDraftPoint({ point: localClick, walls, fences })
|
||||
const snappedStart = snapFenceDraftPoint({
|
||||
point: localClick,
|
||||
walls,
|
||||
fences,
|
||||
step: clickStep,
|
||||
})
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
@@ -494,8 +503,7 @@ export const FenceTool: React.FC = () => {
|
||||
point: localClick,
|
||||
walls,
|
||||
fences,
|
||||
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
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { type GuideNode, useRegistry } from '@pascal-app/core'
|
||||
import { useAssetUrl, useViewer } from '@pascal-app/viewer'
|
||||
import { useLoader } from '@react-three/fiber'
|
||||
import { Suspense, useMemo, useRef } from 'react'
|
||||
import { DoubleSide, type Group, type Texture, TextureLoader } from 'three'
|
||||
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||
import { DoubleSide, type Group, PlaneGeometry, type Texture, TextureLoader } from 'three'
|
||||
import { float, texture } from 'three/tsl'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
|
||||
@@ -34,7 +34,13 @@ export const GuideRenderer = ({ node }: { node: GuideNode }) => {
|
||||
const GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opacity: number }) => {
|
||||
const tex = useLoader(TextureLoader, url) as Texture
|
||||
|
||||
const { width, height, material } = useMemo(() => {
|
||||
// Pass the geometry as a prop. JSX-child `<planeGeometry>` plus
|
||||
// `frustumCulled={false}` lets the mesh submit a first-frame draw
|
||||
// with R3F's empty placeholder BufferGeometry before the child
|
||||
// attaches — WebGPU then flags "Vertex buffer slot 0 required by
|
||||
// [RenderPipeline renderPipeline_MeshBasicNodeMaterial_NNNN] was
|
||||
// not set." Same fix as wall-move-side-handles.tsx / grid.tsx.
|
||||
const { geometry, material } = useMemo(() => {
|
||||
const img = tex.image as HTMLImageElement | ImageBitmap
|
||||
const w = img.width || 1
|
||||
const h = img.height || 1
|
||||
@@ -54,18 +60,28 @@ const GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opaci
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
return { width: planeWidth, height: planeHeight, material: mat }
|
||||
const geom = new PlaneGeometry(planeWidth, planeHeight)
|
||||
geom.boundingBox = null
|
||||
geom.boundingSphere = null
|
||||
|
||||
return { geometry: geom, material: mat }
|
||||
}, [tex, scale, opacity])
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose()
|
||||
material.dispose()
|
||||
},
|
||||
[geometry, material],
|
||||
)
|
||||
|
||||
return (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={geometry}
|
||||
material={material}
|
||||
raycast={() => {}}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<planeGeometry args={[width, height]} boundingBox={null} boundingSphere={null} />
|
||||
</mesh>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,181 @@
|
||||
import { type NodeDefinition, RoofSegmentNode as RoofSegmentNodeSchema } from '@pascal-app/core'
|
||||
import {
|
||||
getActiveRoofHeight,
|
||||
getPitchFromActiveRoofHeight,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type RoofSegmentNode as RoofSegmentNodeType,
|
||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
roofSegmentMoveTarget,
|
||||
roofSegmentResizeAffordance,
|
||||
roofSegmentRotateAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { buildRoofSegmentFloorplan } from './floorplan'
|
||||
import { roofSegmentParametrics } from './parametrics'
|
||||
import { RoofSegmentNode } from './schema'
|
||||
|
||||
const SIDE_HANDLE_OFFSET = 0.3
|
||||
const HEIGHT_HANDLE_OFFSET = 0.3
|
||||
const ROTATE_CORNER_OFFSET = 0.4
|
||||
const ROTATE_RING_OFFSET = 0.08
|
||||
const MIN_ROOF_DIM = 1
|
||||
const MIN_WALL_HEIGHT = 0
|
||||
// Clamp used for handle Y placement so arrows stay visible on flat /
|
||||
// wall-less segments where `wallHeight ≈ 0` would put them on the floor.
|
||||
const MIN_WALL_DISPLAY = 0.3
|
||||
// Pitch is stored in degrees on the schema; same clamp the panel applies.
|
||||
const MIN_PITCH = 0
|
||||
const MAX_PITCH = 85
|
||||
|
||||
// Floor-to-peak height of the assembled segment. Pitch drag drives this
|
||||
// value directly and back-solves the pitch angle via the slope-frame
|
||||
// math in core.
|
||||
function getPeakHeight(n: RoofSegmentNodeType): number {
|
||||
return n.wallHeight + getActiveRoofHeight(n)
|
||||
}
|
||||
|
||||
// Width arrow — anchor='center' so dragging the +X side grows the full
|
||||
// footprint symmetrically (both edges move ±delta). Same idiom as the
|
||||
// elevator / column / shelf width arrow.
|
||||
function roofSegmentWidthHandle(): HandleDescriptor<RoofSegmentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: 'center',
|
||||
min: MIN_ROOF_DIM,
|
||||
currentValue: (n) => n.width,
|
||||
apply: (_n, newValue) => ({ width: newValue }),
|
||||
placement: {
|
||||
position: (n) => [
|
||||
n.width / 2 + SIDE_HANDLE_OFFSET,
|
||||
Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
|
||||
0,
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Depth arrow — symmetric on the +Z side.
|
||||
function roofSegmentDepthHandle(): HandleDescriptor<RoofSegmentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'z',
|
||||
anchor: 'center',
|
||||
min: MIN_ROOF_DIM,
|
||||
currentValue: (n) => n.depth,
|
||||
apply: (_n, newValue) => ({ depth: newValue }),
|
||||
placement: {
|
||||
position: (n) => [
|
||||
0,
|
||||
Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
|
||||
n.depth / 2 + SIDE_HANDLE_OFFSET,
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Wall-height arrow — `anchor: 'min'` keeps the base on the floor and
|
||||
// grows the wall upward. Placed on the -X side at the wall's top edge
|
||||
// so it doesn't stack on the centered pitch arrow when wallHeight ≈ 0
|
||||
// (flat roof / no walls).
|
||||
function roofSegmentWallHeightHandle(): HandleDescriptor<RoofSegmentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_WALL_HEIGHT,
|
||||
currentValue: (n) => n.wallHeight,
|
||||
apply: (_n, newValue) => ({ wallHeight: newValue }),
|
||||
placement: {
|
||||
position: (n) => [
|
||||
-(n.width / 2 + SIDE_HANDLE_OFFSET),
|
||||
Math.max(n.wallHeight, MIN_WALL_DISPLAY),
|
||||
0,
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Pitch arrow — drag the peak vertically to steepen / flatten the roof.
|
||||
// The handle exposes the floor-to-peak height as its currentValue so the
|
||||
// drag delta is a meters value the user can read in the dimension chip;
|
||||
// `apply` inverts the slope-frame math (run = primary-slope footprint
|
||||
// span, rise fraction depends on roofType) to recover the pitch degrees
|
||||
// the new peak corresponds to. Clamped to the schema range [0, 85].
|
||||
//
|
||||
// Placed at the peak's center so it visually attaches to the ridge for
|
||||
// gable / hip / dutch / mansard / gambrel; on shed roofs the geometric
|
||||
// peak sits at one edge, so the arrow floats slightly inboard of the
|
||||
// ridge — acceptable as a "peak-height" affordance and matches the
|
||||
// floorplan-center origin every other handle uses.
|
||||
function roofSegmentPitchHandle(): HandleDescriptor<RoofSegmentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: (n) => n.wallHeight,
|
||||
currentValue: (n) => getPeakHeight(n),
|
||||
apply: (initial, newPeakHeight) => {
|
||||
const roofHeight = Math.max(0, newPeakHeight - initial.wallHeight)
|
||||
const pitch = getPitchFromActiveRoofHeight({
|
||||
roofType: initial.roofType,
|
||||
width: initial.width,
|
||||
depth: initial.depth,
|
||||
roofHeight,
|
||||
gambrelLowerWidthRatio: initial.gambrelLowerWidthRatio,
|
||||
gambrelLowerHeightRatio: initial.gambrelLowerHeightRatio,
|
||||
mansardSteepWidthRatio: initial.mansardSteepWidthRatio,
|
||||
mansardSteepHeightRatio: initial.mansardSteepHeightRatio,
|
||||
dutchHipWidthRatio: initial.dutchHipWidthRatio,
|
||||
dutchHipHeightRatio: initial.dutchHipHeightRatio,
|
||||
})
|
||||
return { pitch: Math.max(MIN_PITCH, Math.min(MAX_PITCH, pitch)) }
|
||||
},
|
||||
placement: {
|
||||
position: (n) => [0, getPeakHeight(n) + HEIGHT_HANDLE_OFFSET, 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-segment rotation gizmo — curved two-headed arrow at the +X / +Z
|
||||
// corner of the footprint, guide ring traces the corner-diagonal radius
|
||||
// on hover / drag. Same pattern as the elevator / column rotate gizmo;
|
||||
// roof-segment stores rotation as a scalar (radians) so the apply patch
|
||||
// just writes back the new scalar.
|
||||
function roofSegmentRotateHandle(): HandleDescriptor<RoofSegmentNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
// Negate the cursor delta to match three.js Y-rotation handedness
|
||||
// (cursor atan2 ticks opposite-handed from `rotation-y`).
|
||||
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const halfX = n.width / 2
|
||||
const halfZ = n.depth / 2
|
||||
const yMid = Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2
|
||||
return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET]
|
||||
},
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) => Math.hypot(n.width / 2, n.depth / 2) + ROTATE_RING_OFFSET,
|
||||
y: (n) => Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const roofSegmentHandles: HandleDescriptor<RoofSegmentNodeType>[] = [
|
||||
roofSegmentWidthHandle(),
|
||||
roofSegmentDepthHandle(),
|
||||
roofSegmentWallHeightHandle(),
|
||||
roofSegmentPitchHandle(),
|
||||
roofSegmentRotateHandle(),
|
||||
]
|
||||
|
||||
/**
|
||||
* Roof segment — Stage A. Child of a roof node, owns the per-segment
|
||||
* polygon + pitch. Geometry is generated by `RoofSystem` (registered
|
||||
@@ -27,17 +200,29 @@ export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: false,
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
parametrics: roofSegmentParametrics,
|
||||
handles: roofSegmentHandles,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
floorplan: buildRoofSegmentFloorplan,
|
||||
// Body-move target. The generic Path 2 fallback writes plan coords
|
||||
// directly to `position`, which is wrong here because the segment's
|
||||
// position is roof-local. `roofSegmentMoveTarget` inverts the parent
|
||||
// roof's transform so the segment lands at the world-plan cursor.
|
||||
floorplanMoveTarget: roofSegmentMoveTarget,
|
||||
// 2D drag affordances for the side resize arrows + corner rotate
|
||||
// arrow emitted by `buildRoofSegmentFloorplan`.
|
||||
floorplanAffordances: {
|
||||
'roof-segment-resize': roofSegmentResizeAffordance,
|
||||
'roof-segment-rotate': roofSegmentRotateAffordance,
|
||||
},
|
||||
|
||||
presentation: {
|
||||
label: 'Roof Segment',
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanMoveTarget,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
const MIN_ROOF_DIM = 1
|
||||
|
||||
type RoofSegmentResizePayload = { axis: 'x' | 'z'; side: 1 | -1 }
|
||||
|
||||
// Resolve world-space center + effective rotation of a roof segment by
|
||||
// composing the parent roof's position + rotation with the segment's
|
||||
// local position. Mirrors the floorplan builder's transform so handles
|
||||
// and affordances stay glued to the rendered footprint.
|
||||
function resolveSegmentFrame(
|
||||
segment: RoofSegmentNode,
|
||||
nodes: Record<AnyNodeId, unknown>,
|
||||
): {
|
||||
cx: number
|
||||
cz: number
|
||||
roofRot: number
|
||||
effRot: number
|
||||
cosRoof: number
|
||||
sinRoof: number
|
||||
} {
|
||||
const roofId = (segment as unknown as { parentId?: AnyNodeId | null }).parentId
|
||||
const roof = roofId ? (nodes[roofId] as RoofNode | undefined) : undefined
|
||||
const roofPosX = roof?.position[0] ?? 0
|
||||
const roofPosZ = roof?.position[2] ?? 0
|
||||
// Floor-plan plots at `-rotation` so SVG-CW matches Three.js-CCW (see
|
||||
// `buildRoofSegmentFloorplan` for the rationale). This frame mirrors
|
||||
// the builder's transform so affordance cx/cz line up with where the
|
||||
// segment actually renders, and the cursor projection in `effRot`
|
||||
// works in the same coord system.
|
||||
const roofRot = -(roof?.rotation ?? 0)
|
||||
const cosRoof = Math.cos(roofRot)
|
||||
const sinRoof = Math.sin(roofRot)
|
||||
const localX = segment.position[0]
|
||||
const localZ = segment.position[2]
|
||||
const cx = roofPosX + localX * cosRoof - localZ * sinRoof
|
||||
const cz = roofPosZ + localX * sinRoof + localZ * cosRoof
|
||||
const effRot = roofRot + -(segment.rotation ?? 0)
|
||||
return { cx, cz, roofRot, effRot, cosRoof, sinRoof }
|
||||
}
|
||||
|
||||
/**
|
||||
* Roof-segment width / depth drag (floor-plan). Mirrors the 3D
|
||||
* `linear-resize` handles in `definition.ts` — `anchor: 'center'`
|
||||
* means dragging outward on either +/-X (or +/-Z) edge grows the
|
||||
* dimension by 2× the segment-local cursor offset while the segment's
|
||||
* roof-local position stays put. Projects the plan cursor onto the
|
||||
* segment's effective rotation (roof.rotation + segment.rotation) so
|
||||
* the math survives any parent-roof rotation.
|
||||
*/
|
||||
export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> = {
|
||||
start({ node, payload, nodes, initialPlanPoint }) {
|
||||
const { axis, side } = payload as RoofSegmentResizePayload
|
||||
const segmentId = node.id as AnyNodeId
|
||||
const initialValue = axis === 'x' ? node.width : node.depth
|
||||
const { cx, cz, effRot } = resolveSegmentFrame(node, nodes)
|
||||
const cosEff = Math.cos(effRot)
|
||||
const sinEff = Math.sin(effRot)
|
||||
// Project (planPoint - center) onto the segment's local X or Z axis
|
||||
// (world directions of those axes are (cosEff, sinEff) and
|
||||
// (-sinEff, cosEff)).
|
||||
const projectLocalAxis = (px: number, pz: number): number => {
|
||||
const dx = px - cx
|
||||
const dz = pz - cz
|
||||
return axis === 'x' ? dx * cosEff + dz * sinEff : -dx * sinEff + dz * cosEff
|
||||
}
|
||||
const initialLocal = projectLocalAxis(initialPlanPoint[0], initialPlanPoint[1])
|
||||
let lastValue = initialValue
|
||||
|
||||
return {
|
||||
affectedIds: [segmentId],
|
||||
apply({ planPoint }) {
|
||||
const currentLocal = projectLocalAxis(planPoint[0], planPoint[1])
|
||||
const delta = (currentLocal - initialLocal) * side
|
||||
const newValue = Math.max(MIN_ROOF_DIM, initialValue + 2 * delta)
|
||||
lastValue = newValue
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(segmentId, axis === 'x' ? { width: newValue } : { depth: newValue })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(segmentId, axis === 'x' ? { width: lastValue } : { depth: lastValue })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Roof-segment rotation drag (floor-plan). Sister to the 3D `arc-resize`
|
||||
* handle. Same `- delta` convention as the 3D handle: the floor-plan
|
||||
* builder plots the footprint at `-(roof.rotation + segment.rotation)`
|
||||
* (see `buildRoofSegmentFloorplan`'s `rotation` local), so the 2D
|
||||
* view rotates the same direction as 3D for the same `rotation` value,
|
||||
* and the same cursor gesture writes the same sign in both views.
|
||||
*/
|
||||
export const roofSegmentRotateAffordance: FloorplanAffordance<RoofSegmentNode> = {
|
||||
start({ node, nodes, initialPlanPoint }) {
|
||||
const segmentId = node.id as AnyNodeId
|
||||
const initialRotation = node.rotation ?? 0
|
||||
const { cx, cz } = resolveSegmentFrame(node, nodes)
|
||||
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
|
||||
let lastRotation = initialRotation
|
||||
|
||||
return {
|
||||
affectedIds: [segmentId],
|
||||
apply({ planPoint }) {
|
||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
||||
let delta = currentAngle - initialAngle
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
lastRotation = initialRotation - delta
|
||||
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Roof-segment body-move target (floor-plan). The generic Path 2 move
|
||||
* fallback writes the cursor's plan position straight into `position`,
|
||||
* which is wrong for roof segments because `position` is **roof-local**
|
||||
* (the floorplan builder composes parent roof's transform to render).
|
||||
* This target inverts the parent roof's transform so the segment moves
|
||||
* to the cursor's WORLD-plan position, not to a roof-local interpretation
|
||||
* of those world coords. Falls back to identity for orphaned segments.
|
||||
*/
|
||||
export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ node, nodes }) => {
|
||||
const segmentId = node.id as AnyNodeId
|
||||
const initialY = node.position[1]
|
||||
const { roofRot, cosRoof, sinRoof } = resolveSegmentFrame(node, nodes)
|
||||
const roofId = (node as unknown as { parentId?: AnyNodeId | null }).parentId
|
||||
const roof = roofId ? (nodes[roofId] as RoofNode | undefined) : undefined
|
||||
const roofPosX = roof?.position[0] ?? 0
|
||||
const roofPosZ = roof?.position[2] ?? 0
|
||||
// Inverse of the forward transform `[cosRoof, -sinRoof; sinRoof, cosRoof]`
|
||||
// is `[cosRoof, sinRoof; -sinRoof, cosRoof]`. Used to project world cursor
|
||||
// back into roof-local coords.
|
||||
void roofRot
|
||||
let lastLocal: [number, number, number] = [
|
||||
node.position[0],
|
||||
node.position[1],
|
||||
node.position[2],
|
||||
]
|
||||
|
||||
return {
|
||||
affectedIds: [segmentId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const dx = planPoint[0] - roofPosX
|
||||
const dz = planPoint[1] - roofPosZ
|
||||
let localX = dx * cosRoof + dz * sinRoof
|
||||
let localZ = -dx * sinRoof + dz * cosRoof
|
||||
// 0.5m grid snap (alt held disables). Mirrors the generic Path 2
|
||||
// fallback's `snapPointToGrid` step so floor-plan moves feel
|
||||
// consistent across kinds.
|
||||
if (!modifiers.altKey) {
|
||||
localX = Math.round(localX * 2) / 2
|
||||
localZ = Math.round(localZ * 2) / 2
|
||||
}
|
||||
lastLocal = [localX, initialY, localZ]
|
||||
useScene.getState().updateNode(segmentId, { position: lastLocal })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(segmentId, { position: lastLocal })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,20 @@ export function buildRoofSegmentFloorplan(
|
||||
const roof = ctx.parent as RoofNode | null
|
||||
if (!roof || roof.type !== 'roof') return null
|
||||
|
||||
// Segment center in world coords: parent roof's transform applied to
|
||||
// the segment's local position offset.
|
||||
const cosRoof = Math.cos(roof.rotation)
|
||||
const sinRoof = Math.sin(roof.rotation)
|
||||
// Segment center in world coords. Floor-plan plots at `-rotation` so
|
||||
// SVG's CW-with-y-down `rotate` direction ends up matching Three.js
|
||||
// Y-rotation (CCW from top-down). The standard math rotation matrix
|
||||
// applied to (localX, localZ) with `+rotation` gives screen-CW in
|
||||
// SVG; negating the rotation gives screen-CCW = matches Three.js.
|
||||
const planRoofRotation = -roof.rotation
|
||||
const cosRoof = Math.cos(planRoofRotation)
|
||||
const sinRoof = Math.sin(planRoofRotation)
|
||||
const localX = node.position[0]
|
||||
const localZ = node.position[2]
|
||||
const cx = roof.position[0] + localX * cosRoof - localZ * sinRoof
|
||||
const cz = roof.position[2] + localX * sinRoof + localZ * cosRoof
|
||||
|
||||
const rotation = roof.rotation + node.rotation
|
||||
const rotation = -(roof.rotation + node.rotation)
|
||||
const cos = Math.cos(rotation)
|
||||
const sin = Math.sin(rotation)
|
||||
const halfWidth = node.width / 2
|
||||
@@ -54,23 +58,40 @@ export function buildRoofSegmentFloorplan(
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
const stroke =
|
||||
showSelectedChrome && palette ? palette.selectedStroke : 'rgba(125, 211, 252, 0.82)'
|
||||
const fill = showSelectedChrome ? '#fed7aa' : 'rgba(56, 189, 248, 0.16)'
|
||||
// Black architectural outline by default; palette accent on select.
|
||||
// Mirrors the elevator / column style so all structural elements read
|
||||
// the same in the floor plan.
|
||||
const baseInk = '#111111'
|
||||
const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseInk
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
// Invisible hit-target — full footprint, transparent fill, captures
|
||||
// clicks across the entire roof rectangle (so the user doesn't need
|
||||
// to pixel-hunt the outline strokes).
|
||||
{
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill,
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
// Visible outline.
|
||||
{
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill: showSelectedChrome ? '#fed7aa' : 'none',
|
||||
fillOpacity: showSelectedChrome ? 0.55 : 0,
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.04 : 0.025,
|
||||
strokeLinejoin: 'round',
|
||||
opacity: 0.85,
|
||||
strokeWidth: showSelectedChrome ? 0.035 : 0.025,
|
||||
strokeLinejoin: 'miter',
|
||||
},
|
||||
]
|
||||
|
||||
// Ridge line — only for pitched segments, not flat roofs.
|
||||
// Ridge line — only for pitched segments, not flat roofs. Dashed
|
||||
// black so it reads as the ridge (axis of the pitch) without
|
||||
// competing with the perimeter outline.
|
||||
if (node.roofType !== 'flat') {
|
||||
const ridgeAxis =
|
||||
node.roofType === 'gable' || node.roofType === 'gambrel'
|
||||
@@ -88,18 +109,71 @@ export function buildRoofSegmentFloorplan(
|
||||
y1: cz - halfSpan * Math.sin(axisAngle),
|
||||
x2: cx + halfSpan * Math.cos(axisAngle),
|
||||
y2: cz + halfSpan * Math.sin(axisAngle),
|
||||
stroke: showSelectedChrome ? '#eff6ff' : 'rgba(186, 230, 253, 0.84)',
|
||||
strokeWidth: 1.4,
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
stroke,
|
||||
strokeWidth: 0.02,
|
||||
strokeDasharray: '0.1 0.08',
|
||||
strokeLinecap: 'butt',
|
||||
opacity: 0.85,
|
||||
})
|
||||
}
|
||||
|
||||
// Selection chrome — orange move-handle dot at the centre, four
|
||||
// perpendicular side resize-arrows (width on X, depth on Z), and a
|
||||
// rotate-arrow at the +X/+Z corner. Sister to the 3D handles in
|
||||
// `definition.ts`. Resize/rotate route through the matching
|
||||
// `floorplanAffordances`; the dot drives body-move via
|
||||
// `def.floorplanMoveTarget`.
|
||||
if (isSelected) {
|
||||
children.push({
|
||||
kind: 'move-handle',
|
||||
point: [cx, cz],
|
||||
})
|
||||
|
||||
const sideArrowOffset = 0.12
|
||||
const rotateCornerOffset = 0.22
|
||||
const halfW = node.width / 2
|
||||
const halfD = node.depth / 2
|
||||
// Effective rotation = parent roof rotation + segment-local rotation.
|
||||
// Reuse `cos` / `sin` from the corner computation above (they were
|
||||
// computed for the same `rotation` value).
|
||||
const rotateLocal = (lx: number, ly: number): [number, number] => [
|
||||
lx * cos - ly * sin,
|
||||
lx * sin + ly * cos,
|
||||
]
|
||||
const sides: Array<{
|
||||
local: [number, number]
|
||||
localAngle: number
|
||||
axis: 'x' | 'z'
|
||||
side: 1 | -1
|
||||
}> = [
|
||||
{ local: [halfW + sideArrowOffset, 0], localAngle: 0, axis: 'x', side: 1 },
|
||||
{ local: [-(halfW + sideArrowOffset), 0], localAngle: Math.PI, axis: 'x', side: -1 },
|
||||
{ local: [0, halfD + sideArrowOffset], localAngle: Math.PI / 2, axis: 'z', side: 1 },
|
||||
{ local: [0, -(halfD + sideArrowOffset)], localAngle: -Math.PI / 2, axis: 'z', side: -1 },
|
||||
]
|
||||
for (const s of sides) {
|
||||
const [ox, oz] = rotateLocal(s.local[0], s.local[1])
|
||||
const [tx, tz] = rotateLocal(Math.cos(s.localAngle), Math.sin(s.localAngle))
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [cx + ox, cz + oz],
|
||||
angle: Math.atan2(tz, tx),
|
||||
affordance: 'roof-segment-resize',
|
||||
payload: { axis: s.axis, side: s.side },
|
||||
})
|
||||
}
|
||||
|
||||
// Rotate-arrow at the +X / +Z corner. Local angle π/4 puts the
|
||||
// curved arrow's bow at the diagonal corner so it reads as a
|
||||
// rotation gizmo around the segment centre.
|
||||
const [cornerX, cornerZ] = rotateLocal(halfW + rotateCornerOffset, halfD + rotateCornerOffset)
|
||||
const [radialX, radialZ] = rotateLocal(1, 1)
|
||||
children.push({
|
||||
kind: 'rotate-arrow',
|
||||
point: [cx + cornerX, cz + cornerZ],
|
||||
angle: Math.atan2(radialZ, radialX),
|
||||
affordance: 'roof-segment-rotate',
|
||||
})
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
|
||||
@@ -1,10 +1,108 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import type {
|
||||
HandleDescriptor,
|
||||
NodeDefinition,
|
||||
ShelfNode as ShelfNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
|
||||
import { buildShelfFloorplan } from './floorplan'
|
||||
import { shelfFloorplanMoveTarget } from './floorplan-move'
|
||||
import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry'
|
||||
import { shelfParametrics } from './parametrics'
|
||||
import { ShelfNode } from './schema'
|
||||
|
||||
const SIDE_HANDLE_OFFSET = 0.18
|
||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||
const ROTATE_CORNER_OFFSET = 0.32
|
||||
const ROTATE_RING_OFFSET = 0.04
|
||||
const MIN_SHELF_WIDTH = 0.3
|
||||
const MIN_SHELF_DEPTH = 0.1
|
||||
const MIN_SHELF_HEIGHT = 0.05
|
||||
|
||||
// Width arrow — anchor='center' so dragging the +X side grows the full
|
||||
// width symmetrically (both edges move ±delta), matching the column /
|
||||
// elevator pattern.
|
||||
function shelfWidthHandle(): HandleDescriptor<ShelfNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: 'center',
|
||||
min: MIN_SHELF_WIDTH,
|
||||
currentValue: (n) => n.width,
|
||||
apply: (_n, newValue) => ({ width: newValue }),
|
||||
placement: {
|
||||
position: (n) => [n.width / 2 + SIDE_HANDLE_OFFSET, n.height / 2, 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Depth arrow — symmetric on the +Z side.
|
||||
function shelfDepthHandle(): HandleDescriptor<ShelfNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'z',
|
||||
anchor: 'center',
|
||||
min: MIN_SHELF_DEPTH,
|
||||
currentValue: (n) => n.depth,
|
||||
apply: (_n, newValue) => ({ depth: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, n.height / 2, n.depth / 2 + SIDE_HANDLE_OFFSET],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Height arrow — anchor='min' so the base stays on the floor and the
|
||||
// top edge follows the cursor.
|
||||
function shelfHeightHandle(): HandleDescriptor<ShelfNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_SHELF_HEIGHT,
|
||||
currentValue: (n) => n.height,
|
||||
apply: (_n, newValue) => ({ height: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, n.height + HEIGHT_HANDLE_OFFSET, 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-shelf rotation gizmo — curved two-headed arrow at the front of
|
||||
// the footprint, guide ring traces the corner-diagonal radius on hover
|
||||
// / drag. Same pattern as the elevator / column rotate gizmo; differs
|
||||
// only because shelf stores rotation as a `[x, y, z]` tuple, so the
|
||||
// apply patch writes back the whole tuple with Y mutated.
|
||||
function shelfRotateHandle(): HandleDescriptor<ShelfNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
apply: (initial, delta) => {
|
||||
const r = initial.rotation ?? [0, 0, 0]
|
||||
// Negate to match three.js Y-rotation handedness (cursor atan2
|
||||
// ticks opposite-handed from `rotation-y`).
|
||||
return { rotation: [r[0], (r[1] ?? 0) - delta, r[2]] as [number, number, number] }
|
||||
},
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const halfZ = n.depth / 2
|
||||
const yMid = Math.max(n.height, MIN_SHELF_HEIGHT) / 2
|
||||
return [n.width / 2, yMid, halfZ + ROTATE_CORNER_OFFSET]
|
||||
},
|
||||
// Tilt the curve toward the shelf's front face.
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) => Math.hypot(n.width / 2, n.depth / 2) + ROTATE_RING_OFFSET,
|
||||
y: (n) => Math.max(n.height, MIN_SHELF_HEIGHT) / 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function shelfHandles(_node: ShelfNodeType): HandleDescriptor<ShelfNodeType>[] {
|
||||
return [shelfWidthHandle(), shelfDepthHandle(), shelfHeightHandle(), shelfRotateHandle()]
|
||||
}
|
||||
|
||||
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
||||
kind: 'shelf',
|
||||
schemaVersion: 2,
|
||||
@@ -82,6 +180,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
||||
},
|
||||
|
||||
parametrics: shelfParametrics,
|
||||
handles: shelfHandles,
|
||||
|
||||
// Three-checkbox composition: shelf needs only pure builder functions.
|
||||
// The framework's <ParametricNodeRenderer> + <GeometrySystem> handle 3D
|
||||
@@ -99,6 +198,15 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
||||
// transforms during drag for real-time 3D sync and commits via a
|
||||
// single tracked `updateNode`.
|
||||
floorplanMoveTarget: shelfFloorplanMoveTarget,
|
||||
// 2D drag affordances for the resize chevrons + rotate-arrow emitted
|
||||
// when the shelf is selected. `shelf-resize` handles width / depth
|
||||
// (the payload's `dim` discriminator); `shelf-rotate` is the corner
|
||||
// arc-arrow that drives `rotation[1]`. Body move stays on the
|
||||
// action-menu Move button → `shelfFloorplanMoveTarget` above.
|
||||
floorplanAffordances: {
|
||||
'shelf-resize': shelfResizeAffordance,
|
||||
'shelf-rotate': shelfRotateAffordance,
|
||||
},
|
||||
|
||||
preview: () => import('./preview'),
|
||||
tool: () => import('./tool'),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type ShelfNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
// Mirror the 3D handles in `shelf/definition.ts` so a drag can't push a
|
||||
// value past what the renderer / geometry builder accepts.
|
||||
const MIN_SHELF_WIDTH = 0.3
|
||||
const MIN_SHELF_DEPTH = 0.1
|
||||
|
||||
export type ShelfResizePayload = {
|
||||
dim: 'width' | 'depth'
|
||||
// Plan-space direction of the arrow's outward tip. Captured at emit
|
||||
// time so a mid-drag rotation can't drift the projection basis.
|
||||
planAxis: [number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
* Single drag handler for both shelf size arrows. Mirrors the 3D
|
||||
* `shelfWidthHandle` / `shelfDepthHandle` (`anchor: 'center'` — cursor
|
||||
* delta `d` along the outward axis grows the dimension by `2·d` so both
|
||||
* faces move ±d and the centre stays put).
|
||||
*/
|
||||
export const shelfResizeAffordance: FloorplanAffordance<ShelfNode> = {
|
||||
start({ node, payload, initialPlanPoint }) {
|
||||
const { dim, planAxis } = payload as ShelfResizePayload
|
||||
const shelfId = node.id as AnyNodeId
|
||||
const [ax, ay] = planAxis
|
||||
const initialProj = initialPlanPoint[0] * ax + initialPlanPoint[1] * ay
|
||||
const initialWidth = node.width
|
||||
const initialDepth = node.depth
|
||||
|
||||
let lastPatch: Partial<ShelfNode> = {}
|
||||
|
||||
return {
|
||||
affectedIds: [shelfId],
|
||||
apply({ planPoint }) {
|
||||
const currentProj = planPoint[0] * ax + planPoint[1] * ay
|
||||
const projDelta = currentProj - initialProj
|
||||
if (dim === 'width') {
|
||||
lastPatch = { width: Math.max(MIN_SHELF_WIDTH, initialWidth + 2 * projDelta) }
|
||||
} else {
|
||||
lastPatch = { depth: Math.max(MIN_SHELF_DEPTH, initialDepth + 2 * projDelta) }
|
||||
}
|
||||
useScene.getState().updateNode(shelfId, lastPatch)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
if (Object.keys(lastPatch).length > 0) {
|
||||
useScene.getState().updateNode(shelfId, lastPatch)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-shelf rotation drag (floor-plan). Sister to the 3D
|
||||
* `shelfRotateHandle` (arc-resize). Cursor angle around the shelf
|
||||
* centre drives `rotation[1]` (Y axis). Shelf stores rotation as a
|
||||
* `[x, y, z]` tuple — the patch preserves the X / Z slots.
|
||||
*
|
||||
* Same `- delta` convention as the 3D handle: the floor-plan builder
|
||||
* plots the footprint at `-rotation[1]` (see `buildShelfFloorplan`'s
|
||||
* `planRy`), so the 2D view rotates the same direction as 3D for the
|
||||
* same `rotation` value and the same cursor gesture writes the same
|
||||
* sign in both views.
|
||||
*/
|
||||
export const shelfRotateAffordance: FloorplanAffordance<ShelfNode> = {
|
||||
start({ node, initialPlanPoint }) {
|
||||
const shelfId = node.id as AnyNodeId
|
||||
const r = node.rotation ?? [0, 0, 0]
|
||||
const initialRotationY = r[1] ?? 0
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
|
||||
let lastRotation: [number, number, number] = [r[0], initialRotationY, r[2]]
|
||||
|
||||
return {
|
||||
affectedIds: [shelfId],
|
||||
apply({ planPoint }) {
|
||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
||||
let delta = currentAngle - initialAngle
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
const newRotationY = initialRotationY - delta
|
||||
lastRotation = [r[0], newRotationY, r[2]]
|
||||
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { FloorplanGeometry } from '@pascal-app/core'
|
||||
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
|
||||
import type { ShelfResizePayload } from './floorplan-affordances'
|
||||
import type { ShelfNode } from './schema'
|
||||
|
||||
// Offsets for the floor-plan selection chrome. Resize chevrons sit a
|
||||
// hair off the footprint rim; the rotate-arrow corner sits a bit further
|
||||
// out so it doesn't crowd the resize arrows.
|
||||
const RESIZE_ARROW_OFFSET = 0.12
|
||||
const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
||||
|
||||
/**
|
||||
* 2D floor-plan representation of a shelf. The unit's outer footprint
|
||||
* projects to a rectangle of `width × depth` centered on the shelf's
|
||||
@@ -12,22 +19,30 @@ import type { ShelfNode } from './schema'
|
||||
* stack vertically under the topmost board from a top-down view and
|
||||
* adding them clutters the plan without conveying useful information.
|
||||
*
|
||||
* Coordinates are level-local meters; the floor-plan panel applies the
|
||||
* world→SVG transform via its viewBox. Rotation is radians (three.js
|
||||
* convention); the renderer converts to SVG degrees.
|
||||
* When selected, emits resize chevrons matching the 3D handle set
|
||||
* (width on +X, depth on +Z) plus a rotate-arrow at the front-right
|
||||
* corner. Body move continues to flow through `shelfFloorplanMoveTarget`
|
||||
* (engaged from the action-menu Move button, not from these arrows).
|
||||
*/
|
||||
export function buildShelfFloorplan(node: ShelfNode): FloorplanGeometry {
|
||||
export function buildShelfFloorplan(
|
||||
node: ShelfNode,
|
||||
ctx?: GeometryContext,
|
||||
): FloorplanGeometry {
|
||||
const [px, , pz] = node.position
|
||||
const ry = node.rotation[1] ?? 0
|
||||
// Floor-plan plots at `-ry` so SVG's CW-with-y-down `rotate` direction
|
||||
// ends up visually matching Three.js Y-rotation (CCW from a top-down
|
||||
// view) — same `rotation` value rotates the same way in both views.
|
||||
// Stair already does this; column / shelf / roof-segment now do too.
|
||||
const planRy = -ry
|
||||
const halfW = node.width / 2
|
||||
const halfD = node.depth / 2
|
||||
const isSelected = ctx?.viewState?.selected ?? false
|
||||
|
||||
// Floor-plan fill: a single neutral fill regardless of `material`.
|
||||
// 2D doesn't render the actual paint material — surfaces in plan view
|
||||
// read as outline + tone, not photoreal texture. Using a fixed light
|
||||
// gray keeps the plan visually consistent with the other furniture
|
||||
// kinds (item / column / etc.) which also render as neutral fills.
|
||||
const children: FloorplanGeometry[] = [
|
||||
// read as outline + tone, not photoreal texture.
|
||||
const footprintChildren: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'rect',
|
||||
x: -halfW,
|
||||
@@ -48,7 +63,7 @@ export function buildShelfFloorplan(node: ShelfNode): FloorplanGeometry {
|
||||
const colStep = innerWidth / node.columns
|
||||
for (let c = 1; c < node.columns; c++) {
|
||||
const x = -innerWidth / 2 + c * colStep
|
||||
children.push({
|
||||
footprintChildren.push({
|
||||
kind: 'line',
|
||||
x1: x,
|
||||
y1: -halfD + node.thickness,
|
||||
@@ -61,9 +76,68 @@ export function buildShelfFloorplan(node: ShelfNode): FloorplanGeometry {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const footprintGroup: FloorplanGeometry = {
|
||||
kind: 'group',
|
||||
transform: { translate: [px, pz], rotate: ry },
|
||||
children,
|
||||
transform: { translate: [px, pz], rotate: planRy },
|
||||
children: footprintChildren,
|
||||
}
|
||||
|
||||
if (!isSelected) {
|
||||
return footprintGroup
|
||||
}
|
||||
|
||||
// Selection chrome lives in world coords (not under the rotated
|
||||
// transform group) so the cursor projection in the affordance can use
|
||||
// the same coord system the dispatcher's `planPoint` arrives in.
|
||||
// `planRy` (= -ry) is the rotation the SVG group uses; arrows and
|
||||
// their plan-coord projections must use the same.
|
||||
const cosR = Math.cos(planRy)
|
||||
const sinR = Math.sin(planRy)
|
||||
// Plan vectors for the shelf-local axes — SVG `rotate(planRy)` maps
|
||||
// local (1, 0) → (cos planRy, sin planRy) and local (0, 1) → (-sin planRy, cos planRy).
|
||||
const localXInPlan: [number, number] = [cosR, sinR]
|
||||
const localZInPlan: [number, number] = [-sinR, cosR]
|
||||
|
||||
const children: FloorplanGeometry[] = [footprintGroup]
|
||||
|
||||
const emitResizeArrow = (
|
||||
dim: ShelfResizePayload['dim'],
|
||||
localAxis: [number, number],
|
||||
localOffset: number,
|
||||
) => {
|
||||
const planAxis: [number, number] = [
|
||||
localAxis[0] * cosR - localAxis[1] * sinR,
|
||||
localAxis[0] * sinR + localAxis[1] * cosR,
|
||||
]
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [px + planAxis[0] * localOffset, pz + planAxis[1] * localOffset],
|
||||
angle: Math.atan2(planAxis[1], planAxis[0]),
|
||||
affordance: 'shelf-resize',
|
||||
payload: { dim, planAxis } satisfies ShelfResizePayload,
|
||||
})
|
||||
}
|
||||
|
||||
emitResizeArrow('width', [1, 0], halfW + RESIZE_ARROW_OFFSET)
|
||||
emitResizeArrow('depth', [0, 1], halfD + RESIZE_ARROW_OFFSET)
|
||||
|
||||
// Rotate-arrow at the +X / +Z corner — matches the 3D
|
||||
// `shelfRotateHandle` corner placement so users see the rotation
|
||||
// affordance in the same quadrant across views.
|
||||
const cornerLocalX = halfW + ROTATE_ARROW_CORNER_OFFSET
|
||||
const cornerLocalZ = halfD + ROTATE_ARROW_CORNER_OFFSET
|
||||
const cornerPlanX = cornerLocalX * cosR - cornerLocalZ * sinR
|
||||
const cornerPlanY = cornerLocalX * sinR + cornerLocalZ * cosR
|
||||
// The arc-arrow's local +X reads as the radial-outward direction; the
|
||||
// diagonal corner is the (+X +Z) sum direction in shelf-local.
|
||||
const radialPlanX = localXInPlan[0] + localZInPlan[0]
|
||||
const radialPlanY = localXInPlan[1] + localZInPlan[1]
|
||||
children.push({
|
||||
kind: 'rotate-arrow',
|
||||
point: [px + cornerPlanX, pz + cornerPlanY],
|
||||
angle: Math.atan2(radialPlanY, radialPlanX),
|
||||
affordance: 'shelf-rotate',
|
||||
})
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
@@ -265,13 +265,13 @@ export default function SkylightPanel() {
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Type">
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2 px-1 pt-1">
|
||||
{SKYLIGHT_TYPE_ORDER.map((skylightType) => {
|
||||
const isSelected = activeSkylightType === skylightType
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-12 items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-xs transition-colors',
|
||||
'flex min-h-12 items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||
import { PolygonEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — slab boundary editor (registry-driven).
|
||||
@@ -14,10 +14,19 @@ import { useCallback } from 'react'
|
||||
* bracketing — lives in `PolygonEditor` itself.
|
||||
*
|
||||
* Mounted by ToolManager via `def.affordanceTools['boundary-edit']`.
|
||||
*
|
||||
* Drag flow: every pointer tick the editor hands back the in-flight
|
||||
* polygon through `onPolygonPreview`; we mirror it onto
|
||||
* `useLiveNodeOverrides` + `markDirty` so `GeometrySystem` rebuilds
|
||||
* the slab mesh at pointer rate. On release the editor calls
|
||||
* `onPolygonChange` once with the final polygon — that's the single
|
||||
* `updateNode` tracked by undo. The follow-up `onPolygonPreview(null)`
|
||||
* drops the override so subscribers read from the store again.
|
||||
*/
|
||||
export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabId }) => {
|
||||
const slabNode = useScene((s) => s.nodes[slabId])
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const markDirty = useScene((s) => s.markDirty)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
|
||||
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
|
||||
@@ -30,6 +39,30 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
|
||||
[slabId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
const handlePolygonPreview = useCallback(
|
||||
(preview: ReadonlyArray<readonly [number, number]> | null) => {
|
||||
if (preview) {
|
||||
useLiveNodeOverrides.getState().set(slabId, {
|
||||
polygon: preview.map(([x, z]) => [x, z] as [number, number]),
|
||||
})
|
||||
} else {
|
||||
useLiveNodeOverrides.getState().clear(slabId)
|
||||
}
|
||||
markDirty(slabId)
|
||||
},
|
||||
[slabId, markDirty],
|
||||
)
|
||||
|
||||
// Guarantee the override clears if the editor unmounts mid-drag
|
||||
// (selection change, mode switch) so the slab mesh doesn't get stuck
|
||||
// on a stale polygon.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useLiveNodeOverrides.getState().clear(slabId)
|
||||
useScene.getState().markDirty(slabId)
|
||||
}
|
||||
}, [slabId])
|
||||
|
||||
if (!slab?.polygon || slab.polygon.length < 3) return null
|
||||
|
||||
return (
|
||||
@@ -39,6 +72,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
onPolygonPreview={handlePolygonPreview}
|
||||
polygon={slab.polygon}
|
||||
surfaceHeight={slab.elevation ?? 0.05}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import type { HandleDescriptor, NodeDefinition, SlabNode as SlabNodeType } from '@pascal-app/core'
|
||||
import { buildSlabFloorplan } from './floorplan'
|
||||
import {
|
||||
slabAddVertexAffordance,
|
||||
@@ -10,6 +10,48 @@ import { buildSlabGeometry } from './geometry'
|
||||
import { slabParametrics } from './parametrics'
|
||||
import { SlabNode } from './schema'
|
||||
|
||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||
const MIN_SLAB_ELEVATION = 0.02
|
||||
|
||||
function slabPolygonCenter(n: SlabNodeType): [number, number] {
|
||||
const polygon = n.polygon ?? []
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
let cx = 0
|
||||
let cz = 0
|
||||
for (const [x, z] of polygon) {
|
||||
cx += x
|
||||
cz += z
|
||||
}
|
||||
return [cx / polygon.length, cz / polygon.length]
|
||||
}
|
||||
|
||||
// Slab height arrow — vertical chevron at the polygon centroid, just
|
||||
// above the slab's top face. Drags elevation (the extrusion thickness)
|
||||
// with `anchor: 'min'` so the bottom stays at world Y=0 and the top
|
||||
// follows the pointer. Same registry-handle pipeline as the column
|
||||
// height arrow, so live override + commit-on-release come for free.
|
||||
function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_SLAB_ELEVATION,
|
||||
currentValue: (n) => n.elevation ?? 0.05,
|
||||
apply: (_n, newValue) => ({ elevation: newValue }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const [cx, cz] = slabPolygonCenter(n)
|
||||
const elevation = n.elevation ?? 0.05
|
||||
return [cx, elevation + HEIGHT_HANDLE_OFFSET, cz]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function slabHandles(_node: SlabNodeType): HandleDescriptor<SlabNodeType>[] {
|
||||
return [slabHeightHandle()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Slab — Phase 5 batch kind, polygon-based. Stage B: `def.geometry`
|
||||
* drives the rebuild via generic <GeometrySystem>; <ParametricNodeRenderer>
|
||||
@@ -60,6 +102,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
},
|
||||
|
||||
parametrics: slabParametrics,
|
||||
handles: slabHandles,
|
||||
|
||||
// Stage D: kind-owned placement tool. Multi-click polygon drawing
|
||||
// with axis/45° snap (Shift to defeat).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||
import { PolygonEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — slab hole editor (registry-driven).
|
||||
@@ -18,6 +18,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
|
||||
}) => {
|
||||
const slabNode = useScene((s) => s.nodes[slabId])
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const markDirty = useScene((s) => s.markDirty)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
|
||||
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
|
||||
@@ -34,6 +35,31 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
|
||||
[slabId, holeIndex, holes, updateNode, setSelection],
|
||||
)
|
||||
|
||||
// Live-preview the in-flight hole onto the slab via
|
||||
// `useLiveNodeOverrides.holes` so `GeometrySystem` rebuilds the CSG
|
||||
// cut while the user is still dragging. Single store commit happens
|
||||
// through `handlePolygonChange` on release.
|
||||
const handlePolygonPreview = useCallback(
|
||||
(preview: ReadonlyArray<readonly [number, number]> | null) => {
|
||||
if (preview) {
|
||||
const updatedHoles = [...holes]
|
||||
updatedHoles[holeIndex] = preview.map(([x, z]) => [x, z] as [number, number])
|
||||
useLiveNodeOverrides.getState().set(slabId, { holes: updatedHoles })
|
||||
} else {
|
||||
useLiveNodeOverrides.getState().clear(slabId)
|
||||
}
|
||||
markDirty(slabId)
|
||||
},
|
||||
[slabId, holeIndex, holes, markDirty],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useLiveNodeOverrides.getState().clear(slabId)
|
||||
useScene.getState().markDirty(slabId)
|
||||
}
|
||||
}, [slabId])
|
||||
|
||||
if (!(slab && hole) || hole.length < 3) return null
|
||||
|
||||
return (
|
||||
@@ -44,6 +70,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
onPolygonPreview={handlePolygonPreview}
|
||||
polygon={hole}
|
||||
surfaceHeight={slab.elevation ?? 0.05}
|
||||
/>
|
||||
|
||||
@@ -87,6 +87,75 @@ describe('getAnalyticalNormal', () => {
|
||||
expect(n.z).toBeGreaterThan(0)
|
||||
expect(n.y).toBeGreaterThan(0)
|
||||
})
|
||||
// Regression: previously `(0, depth, -rh)` flipped the shed preview's
|
||||
// tilt opposite the actual slope (peak at -d/2, eave at +d/2 → outward
|
||||
// normal tilts toward +Z, the low side).
|
||||
test('shed returns normal tilted toward the +z low side', () => {
|
||||
const n = getAnalyticalNormal(0, 0, fixtureSegment({ roofType: 'shed' }))
|
||||
expect(n.z).toBeGreaterThan(0)
|
||||
expect(n.y).toBeGreaterThan(0)
|
||||
})
|
||||
test('gambrel lower tier z=+halfD tilts toward +z', () => {
|
||||
const seg = fixtureSegment({ roofType: 'gambrel' })
|
||||
const n = getAnalyticalNormal(0, seg.depth / 2 - 0.01, seg)
|
||||
expect(n.z).toBeGreaterThan(0)
|
||||
expect(n.y).toBeGreaterThan(0)
|
||||
})
|
||||
test('gambrel upper tier (|z|<mz) uses shallower slope than lower tier', () => {
|
||||
const seg = fixtureSegment({
|
||||
roofType: 'gambrel',
|
||||
gambrelLowerWidthRatio: 0.5,
|
||||
gambrelLowerHeightRatio: 0.7,
|
||||
})
|
||||
const lower = getAnalyticalNormal(0, seg.depth / 2 - 0.01, seg)
|
||||
const upper = getAnalyticalNormal(0, 0.01, seg)
|
||||
// Both tilt toward +z; upper tier is shallower, so its Z component
|
||||
// (sin θ) is smaller than the lower tier's.
|
||||
expect(upper.z).toBeGreaterThan(0)
|
||||
expect(upper.z).toBeLessThan(lower.z)
|
||||
})
|
||||
test('hip +x face tilts toward +x, not +z', () => {
|
||||
const n = getAnalyticalNormal(2, 0, fixtureSegment({ roofType: 'hip' }))
|
||||
expect(n.x).toBeGreaterThan(0)
|
||||
expect(Math.abs(n.z)).toBeLessThan(1e-6)
|
||||
expect(n.y).toBeGreaterThan(0)
|
||||
})
|
||||
// Regression: mansard previously fell through to gable code, ignoring
|
||||
// the X axis. Points near the +X edge tilted toward +Z instead of +X.
|
||||
test('mansard +x steep band tilts toward +x', () => {
|
||||
const seg = fixtureSegment({ roofType: 'mansard' })
|
||||
const n = getAnalyticalNormal(seg.width / 2 - 0.01, 0, seg)
|
||||
expect(n.x).toBeGreaterThan(0)
|
||||
expect(Math.abs(n.z)).toBeLessThan(1e-6)
|
||||
expect(n.y).toBeGreaterThan(0)
|
||||
})
|
||||
test('mansard top hip (inside waist) is shallower than the steep band', () => {
|
||||
const seg = fixtureSegment({
|
||||
roofType: 'mansard',
|
||||
mansardSteepWidthRatio: 0.2,
|
||||
mansardSteepHeightRatio: 0.7,
|
||||
})
|
||||
const steep = getAnalyticalNormal(seg.width / 2 - 0.01, 0, seg)
|
||||
const top = getAnalyticalNormal(0.01, 0, seg)
|
||||
expect(top.x).toBeGreaterThan(0)
|
||||
expect(top.x).toBeLessThan(steep.x)
|
||||
})
|
||||
// Regression: dutch previously fell through to gable code, ignoring
|
||||
// the X axis. Hip ends rendered with the wrong tilt direction.
|
||||
test('dutch +x hip end tilts toward +x (w>=d)', () => {
|
||||
const seg = fixtureSegment({ roofType: 'dutch', width: 8, depth: 6 })
|
||||
const n = getAnalyticalNormal(seg.width / 2 - 0.01, 0, seg)
|
||||
expect(n.x).toBeGreaterThan(0)
|
||||
expect(Math.abs(n.z)).toBeLessThan(1e-6)
|
||||
expect(n.y).toBeGreaterThan(0)
|
||||
})
|
||||
test('dutch +z gable side tilts toward +z (w>=d)', () => {
|
||||
const seg = fixtureSegment({ roofType: 'dutch', width: 8, depth: 6 })
|
||||
const n = getAnalyticalNormal(0, seg.depth / 2 - 0.01, seg)
|
||||
expect(n.z).toBeGreaterThan(0)
|
||||
expect(Math.abs(n.x)).toBeLessThan(1e-6)
|
||||
expect(n.y).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeAutoFit', () => {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { getActiveRoofHeight, type RoofSegmentNode, type SolarPanelNode } from '@pascal-app/core'
|
||||
import {
|
||||
getActiveRoofHeight,
|
||||
getSegmentSlopeFrame,
|
||||
ROOF_SHAPE_DEFAULTS,
|
||||
type RoofSegmentNode,
|
||||
type SolarPanelNode,
|
||||
} from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
@@ -217,35 +223,97 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe
|
||||
return peakY - t * rh
|
||||
}
|
||||
|
||||
// Outward normal for a roof surface tilting at angle θ in the horizontal
|
||||
// direction (dx, dz). Derivation: the surface tangent vectors are the
|
||||
// ridge axis (perpendicular to the fall line, horizontal) and the
|
||||
// down-slope direction (cos θ horizontal + −sin θ vertical). Crossing
|
||||
// them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz),
|
||||
// equivalently (dx · tan θ, 1, dz · tan θ) un-normalised.
|
||||
function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 {
|
||||
return new THREE.Vector3(dx * tan, 1, dz * tan).normalize()
|
||||
}
|
||||
|
||||
export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 {
|
||||
const { roofType, depth, width } = seg
|
||||
const rh = getActiveRoofHeight(seg)
|
||||
if (rh === 0) return new THREE.Vector3(0, 1, 0)
|
||||
|
||||
if (roofType === 'gable') {
|
||||
const halfD = depth / 2
|
||||
return new THREE.Vector3(0, halfD, lz >= 0 ? rh : -rh).normalize()
|
||||
}
|
||||
if (roofType === 'shed') {
|
||||
return new THREE.Vector3(0, depth, -rh).normalize()
|
||||
}
|
||||
if (roofType === 'hip') {
|
||||
// All four hip faces share the same slope angle, set by `rh` over
|
||||
// `min(w/2, d/2)` (the eave-to-ridge horizontal reach perpendicular
|
||||
// to the ridge — short axis for both the trapezoidal long faces and
|
||||
// the triangular short faces). Using `depth/2` for the front/back
|
||||
// normal and `width/2` for the sides was correct only when w == d;
|
||||
// for any other aspect ratio it tilted the long-axis faces wrong.
|
||||
const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0
|
||||
const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
|
||||
const slopeReach = Math.min(width, depth) / 2
|
||||
if (fz >= fx) {
|
||||
return new THREE.Vector3(0, slopeReach, lz >= 0 ? rh : -rh).normalize()
|
||||
}
|
||||
return new THREE.Vector3(lx >= 0 ? rh : -rh, slopeReach, 0).normalize()
|
||||
const slope = getSegmentSlopeFrame(seg)
|
||||
if (slope.activeRh === 0 || slope.tanTheta === 0) {
|
||||
return new THREE.Vector3(0, 1, 0)
|
||||
}
|
||||
const primaryTan = slope.tanTheta
|
||||
const halfW = width / 2
|
||||
const halfD = depth / 2
|
||||
return new THREE.Vector3(0, halfD, lz >= 0 ? rh : -rh).normalize()
|
||||
|
||||
// Ridge runs along X — slope falls in ±Z. Gambrel shares the gable
|
||||
// dispatch (its kink-to-eave/lower tier is the primary slope frame).
|
||||
if (roofType === 'gable' || roofType === 'gambrel') {
|
||||
if (roofType === 'gambrel') {
|
||||
// Tier-aware: the upper (shallower) face spans |z| < mz; the
|
||||
// lower (steep) face spans mz < |z| ≤ halfD. Using primaryTan on
|
||||
// the upper tier would tilt the ghost too steeply near the ridge.
|
||||
const lowerWidthRatio =
|
||||
seg.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio
|
||||
const lowerHeightRatio =
|
||||
seg.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio
|
||||
const mz = halfD * lowerWidthRatio
|
||||
if (Math.abs(lz) <= mz) {
|
||||
const upperRise = slope.activeRh * (1 - lowerHeightRatio)
|
||||
const upperRun = mz
|
||||
const upperTan = upperRun > 0 ? upperRise / upperRun : 0
|
||||
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan)
|
||||
}
|
||||
}
|
||||
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
|
||||
}
|
||||
|
||||
// Single slope falling toward +Z (ridge at -Z, eave at +Z).
|
||||
if (roofType === 'shed') {
|
||||
return buildSlopeNormal(0, 1, primaryTan)
|
||||
}
|
||||
|
||||
// 4-sided slopes: the dominant axis chooses which face the point sits
|
||||
// on. Hip is uniform across all four faces. Mansard has a steep outer
|
||||
// band (primaryTan) and a shallow top inside the waist. Dutch has hip
|
||||
// ends and gable sides — both share the same primaryTan from the
|
||||
// slope frame, so directional dispatch is enough.
|
||||
if (roofType === 'hip') {
|
||||
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
|
||||
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
|
||||
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
|
||||
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
|
||||
}
|
||||
|
||||
if (roofType === 'mansard') {
|
||||
const widthRatio = seg.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio
|
||||
const heightRatio = seg.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio
|
||||
const inset = Math.min(width, depth) * widthRatio
|
||||
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
|
||||
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
|
||||
const onZ = fz >= fx
|
||||
const inSteepBand = onZ ? Math.abs(lz) > halfD - inset : Math.abs(lx) > halfW - inset
|
||||
|
||||
let tan = primaryTan
|
||||
if (!inSteepBand) {
|
||||
// Top hip (shallow) above the waist — rises from the waist
|
||||
// rectangle at fraction `heightRatio` of activeRh up to the peak.
|
||||
const topRise = slope.activeRh * (1 - heightRatio)
|
||||
const topRun = Math.max(0, Math.min(halfW, halfD) - inset)
|
||||
tan = topRun > 0 ? topRise / topRun : 0
|
||||
}
|
||||
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan)
|
||||
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan)
|
||||
}
|
||||
|
||||
if (roofType === 'dutch') {
|
||||
// Hip on the short-axis ends, gable on the long-axis sides. Both
|
||||
// share the primary pitch on their primary (eave-band) face, so the
|
||||
// approximation collapses to "pick the dominant axis."
|
||||
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
|
||||
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
|
||||
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
|
||||
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
|
||||
}
|
||||
|
||||
return new THREE.Vector3(0, 1, 0)
|
||||
}
|
||||
|
||||
// ─── Quaternion helper ───────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,101 @@
|
||||
import { type NodeDefinition, StairSegmentNode as StairSegmentNodeSchema } from '@pascal-app/core'
|
||||
import {
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
type StairSegmentNode as StairSegmentNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { stairSegmentParametrics } from './parametrics'
|
||||
import { StairSegmentNode } from './schema'
|
||||
|
||||
const SIDE_HANDLE_OFFSET = 0.24
|
||||
const LENGTH_HANDLE_OFFSET = 0.24
|
||||
const HEIGHT_HANDLE_OFFSET = 0.24
|
||||
const MIN_SEGMENT_WIDTH = 0.4
|
||||
const MIN_SEGMENT_LENGTH = 0.4
|
||||
const MIN_SEGMENT_HEIGHT = 0.1
|
||||
|
||||
// Width grows symmetrically around the chain centerline — the chain owns
|
||||
// segment.position so writing a new center here would be clobbered next
|
||||
// frame by `syncSegmentMeshTransforms`. We just write `width` and let the
|
||||
// chain re-center.
|
||||
function stairSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor<StairSegmentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
// 'min' factor=+1 / 'max' factor=-1 lets each arrow grow the value
|
||||
// when dragged outward (right edge: drag +X grows; left edge: drag -X
|
||||
// grows). Matches the legacy `widthDelta = sign * pointerDelta`.
|
||||
anchor: side === 'right' ? 'min' : 'max',
|
||||
min: MIN_SEGMENT_WIDTH,
|
||||
currentValue: (n) => n.width,
|
||||
apply: (_n, newValue) => ({ width: newValue }),
|
||||
placement: {
|
||||
position: (n) => [
|
||||
(side === 'right' ? 1 : -1) * (n.width / 2 + SIDE_HANDLE_OFFSET),
|
||||
n.height / 2,
|
||||
n.length / 2,
|
||||
],
|
||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||
},
|
||||
portal: 'grandparent',
|
||||
}
|
||||
}
|
||||
|
||||
// Length: segment's back-face (Z = length) anchors against the chain end,
|
||||
// so the run simply extends toward +Z as length grows. anchor='min' →
|
||||
// drag +Z grows length 1:1.
|
||||
//
|
||||
// `rotationY` is intentionally omitted. The generic linear-arrow renderer
|
||||
// already auto-rotates `axis: 'z'` chevrons by `-π/2` so the local +X tip
|
||||
// faces +Z (see `axisRotationY` in `node-arrow-handles.tsx`). Adding our
|
||||
// own `-π/2` here stacks to `-π`, which spins the tip to `-X` and the
|
||||
// chevron reads as sideways across the front edge instead of pointing
|
||||
// forward off the run. Shelf / roof-segment depth handles match this —
|
||||
// neither sets `rotationY` for their `axis: 'z'` arrow.
|
||||
function stairSegmentLengthHandle(): HandleDescriptor<StairSegmentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'z',
|
||||
anchor: 'min',
|
||||
min: MIN_SEGMENT_LENGTH,
|
||||
currentValue: (n) => n.length,
|
||||
apply: (_n, newValue) => ({ length: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, n.height / 2, n.length + LENGTH_HANDLE_OFFSET],
|
||||
},
|
||||
portal: 'grandparent',
|
||||
}
|
||||
}
|
||||
|
||||
// Height applies only to step-flight segments (landings are flat). The
|
||||
// segment's floor is at Y=0; dragging the top grows height upward.
|
||||
function stairSegmentHeightHandle(): HandleDescriptor<StairSegmentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_SEGMENT_HEIGHT,
|
||||
currentValue: (n) => n.height,
|
||||
apply: (_n, newValue) => ({ height: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, n.height + HEIGHT_HANDLE_OFFSET, n.length / 2],
|
||||
},
|
||||
portal: 'grandparent',
|
||||
}
|
||||
}
|
||||
|
||||
function stairSegmentHandles(node: StairSegmentNodeType): HandleDescriptor<StairSegmentNodeType>[] {
|
||||
const handles: HandleDescriptor<StairSegmentNodeType>[] = [
|
||||
stairSegmentWidthHandle('left'),
|
||||
stairSegmentWidthHandle('right'),
|
||||
stairSegmentLengthHandle(),
|
||||
]
|
||||
if (node.segmentType === 'stair') {
|
||||
handles.push(stairSegmentHeightHandle())
|
||||
}
|
||||
return handles
|
||||
}
|
||||
|
||||
/**
|
||||
* Stair segment — Stage A. Child of a stair node; per-flight geometry.
|
||||
* Built by `StairSystem` registered on the parent stair definition.
|
||||
@@ -29,6 +123,7 @@ export const stairSegmentDefinition: NodeDefinition<typeof StairSegmentNode> = {
|
||||
},
|
||||
|
||||
parametrics: stairSegmentParametrics,
|
||||
handles: stairSegmentHandles,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PanelWrapper,
|
||||
SegmentedControl,
|
||||
SliderControl,
|
||||
ToggleControl,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
@@ -204,34 +205,25 @@ export default function StairSegmentPanel() {
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Structure">
|
||||
<div className="flex items-center justify-between px-1 py-1">
|
||||
<span className="text-muted-foreground text-xs">Fill to floor</span>
|
||||
<button
|
||||
className={`relative h-5 w-10 rounded-full transition-colors ${
|
||||
node.fillToFloor ? 'bg-blue-500' : 'bg-[#3e3e3e]'
|
||||
}`}
|
||||
onClick={() => handleUpdate({ fillToFloor: !node.fillToFloor })}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 h-3 w-3 rounded-full bg-white transition-transform ${
|
||||
node.fillToFloor ? 'left-6' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{!node.fillToFloor && (
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={1}
|
||||
min={0.05}
|
||||
onChange={(v) => handleUpdate({ thickness: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
|
||||
<div className="space-y-3">
|
||||
<ToggleControl
|
||||
checked={node.fillToFloor}
|
||||
label="Fill to floor"
|
||||
onChange={(checked) => handleUpdate({ fillToFloor: checked })}
|
||||
/>
|
||||
)}
|
||||
{!node.fillToFloor && (
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={1}
|
||||
min={0.05}
|
||||
onChange={(v) => handleUpdate({ thickness: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
|
||||
@@ -1,5 +1,303 @@
|
||||
import { type NodeDefinition, StairNode as StairNodeSchema } from '@pascal-app/core'
|
||||
import {
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type StairNode as StairNodeType,
|
||||
StairNode as StairNodeSchema,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
const MIN_CURVED_RISE = 0.3
|
||||
const MIN_CURVED_WIDTH = 0.4
|
||||
const MIN_CURVED_INNER_RADIUS_SPIRAL = 0.05
|
||||
const MIN_CURVED_INNER_RADIUS_CURVED = 0.2
|
||||
const MIN_CURVED_SWEEP = Math.PI / 12
|
||||
const MAX_CURVED_SWEEP = Math.PI * 2 - 0.05
|
||||
const CURVED_RISE_OFFSET = 0.35
|
||||
const CURVED_WIDTH_HANDLE_OFFSET = 0.5
|
||||
const CURVED_RADIAL_OFFSET = 0.16
|
||||
const CURVED_SWEEP_RADIAL_OFFSET = 0.3
|
||||
const CURVED_SWEEP_LATERAL_OFFSET = 0.24
|
||||
// Guide rings — outer hugs just outside the rim, inner sits inside the
|
||||
// pillar. Clamp inner so a tiny innerRadius (spiral default 0.05) doesn't
|
||||
// push the ring through the axis.
|
||||
const CURVED_OUTER_RING_OFFSET = 0.2
|
||||
const CURVED_INNER_RING_OFFSET = 0.2
|
||||
const CURVED_INNER_RING_MIN = 0.05
|
||||
// Whole-stair rotation gizmo — curved two-headed arrow at a corner of
|
||||
// the footprint. Same pattern as elevator / column / shelf / roof-segment.
|
||||
const STAIR_ROTATE_CORNER_OFFSET = 0.4
|
||||
const STAIR_ROTATE_RING_OFFSET = 0.08
|
||||
|
||||
type CurvedStairGeom = {
|
||||
isSpiral: boolean
|
||||
stepCount: number
|
||||
totalRise: number
|
||||
innerRadius: number
|
||||
outerRadius: number
|
||||
width: number
|
||||
sweepAngle: number
|
||||
stepSweep: number
|
||||
midRadius: number
|
||||
topAngle: number
|
||||
minInnerRadius: number
|
||||
}
|
||||
|
||||
function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom {
|
||||
const isSpiral = node.stairType === 'spiral'
|
||||
const stepCount = Math.max(2, Math.round(node.stepCount ?? 10))
|
||||
const totalRise = Math.max(node.totalRise ?? 2.5, 0.1)
|
||||
const width = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
|
||||
const minInnerRadius = isSpiral ? MIN_CURVED_INNER_RADIUS_SPIRAL : MIN_CURVED_INNER_RADIUS_CURVED
|
||||
const innerRadius = Math.max(minInnerRadius, node.innerRadius ?? 0.9)
|
||||
const outerRadius = innerRadius + width
|
||||
const sweepAngle = node.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
||||
const stepSweep = sweepAngle / stepCount
|
||||
return {
|
||||
isSpiral,
|
||||
stepCount,
|
||||
totalRise,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
width,
|
||||
sweepAngle,
|
||||
stepSweep,
|
||||
midRadius: (innerRadius + outerRadius) / 2,
|
||||
topAngle: sweepAngle / 2 - stepSweep / 2,
|
||||
minInnerRadius,
|
||||
}
|
||||
}
|
||||
|
||||
function isCurvedOrSpiral(node: StairNodeType): boolean {
|
||||
return node.stairType === 'curved' || node.stairType === 'spiral'
|
||||
}
|
||||
|
||||
function curvedRiseHandle(): HandleDescriptor<StairNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_CURVED_RISE,
|
||||
currentValue: (n) => Math.max(n.totalRise ?? 2.5, 0.1),
|
||||
apply: (_n, newRise) => ({ totalRise: newRise }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const g = readCurvedStairGeometry(n)
|
||||
// Spiral: over the central pillar. Curved: above the upper step's
|
||||
// midline so the arrow sits where users read "top of the run".
|
||||
const x = g.isSpiral ? 0 : g.midRadius * Math.cos(g.topAngle)
|
||||
const z = g.isSpiral ? 0 : g.midRadius * Math.sin(g.topAngle)
|
||||
return [x, g.totalRise + CURVED_RISE_OFFSET, z]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function curvedWidthHandle(): HandleDescriptor<StairNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: 'min',
|
||||
min: MIN_CURVED_WIDTH,
|
||||
currentValue: (n) => Math.max(n.width ?? 1, MIN_CURVED_WIDTH),
|
||||
apply: (_n, newWidth) => ({ width: newWidth }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const g = readCurvedStairGeometry(n)
|
||||
return [g.outerRadius + CURVED_WIDTH_HANDLE_OFFSET, g.totalRise / 2, 0]
|
||||
},
|
||||
},
|
||||
// Outer guide ring — traces the rim while the user interacts with the
|
||||
// width arrow so it's obvious which edge the drag affects.
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) => readCurvedStairGeometry(n).outerRadius + CURVED_OUTER_RING_OFFSET,
|
||||
y: (n) => readCurvedStairGeometry(n).totalRise / 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function curvedInnerRadiusHandle(): HandleDescriptor<StairNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: 'min',
|
||||
min: (n) =>
|
||||
n.stairType === 'spiral' ? MIN_CURVED_INNER_RADIUS_SPIRAL : MIN_CURVED_INNER_RADIUS_CURVED,
|
||||
currentValue: (n) => {
|
||||
const minIR =
|
||||
n.stairType === 'spiral' ? MIN_CURVED_INNER_RADIUS_SPIRAL : MIN_CURVED_INNER_RADIUS_CURVED
|
||||
return Math.max(minIR, n.innerRadius ?? 0.9)
|
||||
},
|
||||
// Adjusting innerRadius alone would also push outerRadius outward,
|
||||
// visually moving the outside of the stair. Compensate by reducing
|
||||
// width by the same amount so the outer rim stays put.
|
||||
apply: (initial, newInner) => {
|
||||
const g = readCurvedStairGeometry(initial)
|
||||
const delta = newInner - g.innerRadius
|
||||
return {
|
||||
innerRadius: newInner,
|
||||
width: Math.max(MIN_CURVED_WIDTH, g.width - delta),
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const g = readCurvedStairGeometry(n)
|
||||
return [g.innerRadius - CURVED_RADIAL_OFFSET, g.totalRise / 2, 0]
|
||||
},
|
||||
rotationY: () => Math.PI,
|
||||
},
|
||||
// Inner guide ring — traces the central pillar. Clamped so a tiny
|
||||
// innerRadius doesn't pull the ring through the axis.
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) => {
|
||||
const g = readCurvedStairGeometry(n)
|
||||
return Math.max(g.innerRadius - CURVED_INNER_RING_OFFSET, CURVED_INNER_RING_MIN)
|
||||
},
|
||||
y: (n) => readCurvedStairGeometry(n).totalRise / 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function curvedSweepHandle(end: 'start' | 'end'): HandleDescriptor<StairNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
end,
|
||||
apply: (initial, delta) => {
|
||||
const initialSweep = initial.sweepAngle ?? Math.PI / 2
|
||||
const initialRotation = (initial.rotation as number) ?? 0
|
||||
const sweepSign = Math.sign(initialSweep) || 1
|
||||
// END handle: cursor angle delta IS the sweep delta.
|
||||
// START handle: cursor angle delta is the negation of the sweep delta.
|
||||
const sweepDelta = end === 'end' ? delta : -delta
|
||||
const targetSweep = initialSweep + sweepDelta
|
||||
const clampedAbs = Math.min(
|
||||
MAX_CURVED_SWEEP,
|
||||
Math.max(MIN_CURVED_SWEEP, Math.abs(targetSweep)),
|
||||
)
|
||||
const newSweep = sweepSign * clampedAbs
|
||||
const appliedDelta = newSweep - initialSweep
|
||||
// Re-orient the stair so the OPPOSITE edge stays world-fixed:
|
||||
// END fixed-start: ΔR = −ΔS / 2
|
||||
// START fixed-end : ΔR = +ΔS / 2
|
||||
const rotationShift = end === 'end' ? -appliedDelta / 2 : appliedDelta / 2
|
||||
return {
|
||||
sweepAngle: newSweep,
|
||||
rotation: initialRotation + rotationShift,
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const g = readCurvedStairGeometry(n)
|
||||
const sweepSign = Math.sign(g.sweepAngle) || 1
|
||||
const z =
|
||||
end === 'end'
|
||||
? sweepSign * CURVED_SWEEP_LATERAL_OFFSET
|
||||
: -sweepSign * CURVED_SWEEP_LATERAL_OFFSET
|
||||
return [g.outerRadius + CURVED_SWEEP_RADIAL_OFFSET, g.totalRise / 2, z]
|
||||
},
|
||||
rotationY: (n) => {
|
||||
const sweepSign = Math.sign(n.sweepAngle ?? Math.PI / 2) || 1
|
||||
return end === 'end' ? -sweepSign * (Math.PI / 2) : sweepSign * (Math.PI / 2)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-stair rotation gizmo. Lives on the stair parent so it rotates
|
||||
// straight + curved + spiral kinds the same way. For straight stairs the
|
||||
// gizmo anchors just outside the +X corner of the run start (the stair's
|
||||
// root sits at the bottom of the first segment, with the chain extending
|
||||
// along +Z). For curved / spiral the gizmo sits at the outer rim, at the
|
||||
// sweep-start side, where there's no other handle in the way. apply()
|
||||
// negates the cursor delta so dragging CCW (atan2 ticks +) rotates the
|
||||
// stair CCW around Y — same convention as elevator / column.
|
||||
function stairRotateGizmoPosition(n: StairNodeType): [number, number, number] {
|
||||
if (isCurvedOrSpiral(n)) {
|
||||
const g = readCurvedStairGeometry(n)
|
||||
const radius = g.outerRadius + STAIR_ROTATE_CORNER_OFFSET
|
||||
// Sweep-start side in node-local frame. Sector is centred on
|
||||
// local +X (sweep bisector = 0), so start = -sweep/2.
|
||||
const angle = -g.sweepAngle / 2
|
||||
return [radius * Math.cos(angle), g.totalRise / 2, radius * Math.sin(angle)]
|
||||
}
|
||||
const width = Math.max(n.width ?? 1, MIN_CURVED_WIDTH)
|
||||
const yMid = Math.max(n.totalRise ?? 2.5, 0.1) / 2
|
||||
return [
|
||||
width / 2 + STAIR_ROTATE_CORNER_OFFSET,
|
||||
yMid,
|
||||
-STAIR_ROTATE_CORNER_OFFSET,
|
||||
]
|
||||
}
|
||||
|
||||
function stairRotateHandle(): HandleDescriptor<StairNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||
placement: {
|
||||
position: stairRotateGizmoPosition,
|
||||
// The curved-arrow geometry's bow points along its local +X. Rotate
|
||||
// the icon so the bow points radially outward — away from the
|
||||
// stair's center — so the curve hugs the body's outline at the
|
||||
// gizmo's corner instead of cutting into it. rotateY(−α) maps local
|
||||
// +X to the outward radial direction (same handedness rule as
|
||||
// elevator / column, but here the gizmo lands in different
|
||||
// quadrants per stair kind so the tilt is position-derived rather
|
||||
// than a fixed −π/4).
|
||||
rotationY: (n) => {
|
||||
const [px, , pz] = stairRotateGizmoPosition(n)
|
||||
return -Math.atan2(pz, px)
|
||||
},
|
||||
},
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) => {
|
||||
if (isCurvedOrSpiral(n)) {
|
||||
const g = readCurvedStairGeometry(n)
|
||||
return g.outerRadius + STAIR_ROTATE_CORNER_OFFSET + STAIR_ROTATE_RING_OFFSET
|
||||
}
|
||||
const width = Math.max(n.width ?? 1, MIN_CURVED_WIDTH)
|
||||
return (
|
||||
Math.hypot(width / 2 + STAIR_ROTATE_CORNER_OFFSET, STAIR_ROTATE_CORNER_OFFSET) +
|
||||
STAIR_ROTATE_RING_OFFSET
|
||||
)
|
||||
},
|
||||
y: (n) => Math.max(n.totalRise ?? 2.5, 0.1) / 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
|
||||
// Straight stairs have no parent-level shape arrows — the segment
|
||||
// children each render their own (width / length / height). Curved +
|
||||
// spiral stairs use 5 arrows directly on the parent (no segments).
|
||||
// The whole-stair rotation gizmo is universal: every stair kind
|
||||
// exposes the same curved-arrow rotate handle.
|
||||
const handles: HandleDescriptor<StairNodeType>[] = []
|
||||
if (isCurvedOrSpiral(node)) {
|
||||
handles.push(
|
||||
curvedRiseHandle(),
|
||||
curvedWidthHandle(),
|
||||
curvedInnerRadiusHandle(),
|
||||
curvedSweepHandle('start'),
|
||||
curvedSweepHandle('end'),
|
||||
)
|
||||
}
|
||||
handles.push(stairRotateHandle())
|
||||
return handles
|
||||
}
|
||||
import {
|
||||
curvedStairInnerRadiusAffordance,
|
||||
curvedStairSweepAffordance,
|
||||
curvedStairWidthAffordance,
|
||||
segmentLengthAffordance,
|
||||
segmentWidthAffordance,
|
||||
stairRotateAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { buildStairFloorplan } from './floorplan'
|
||||
import { stairFloorplanMoveTarget } from './floorplan-move'
|
||||
import { stairParametrics } from './parametrics'
|
||||
import { StairNode } from './schema'
|
||||
|
||||
@@ -28,6 +326,7 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
|
||||
},
|
||||
|
||||
parametrics: stairParametrics,
|
||||
handles: stairHandles,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
@@ -44,6 +343,26 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
|
||||
// compute their own polygon in isolation. See
|
||||
// `nodes/src/stair/floorplan.ts` for the emitter.
|
||||
floorplan: buildStairFloorplan,
|
||||
floorplanMoveTarget: stairFloorplanMoveTarget,
|
||||
|
||||
// 2D drag affordances mirror the 3D in-world arrows on selected stairs:
|
||||
// - `segment-width` / `segment-length` drive per-segment side & length
|
||||
// arrows on straight stairs (sister to `StairSegmentSideArrow` /
|
||||
// `StairSegmentLengthArrow` in stair-segment-handles.tsx).
|
||||
// - `curved-width` / `curved-inner-radius` / `curved-sweep` drive the
|
||||
// parent-stair arrows for curved & spiral kinds (sister to
|
||||
// `CurvedStairWidthArrow` / `CurvedStairInnerRadiusArrow` /
|
||||
// `CurvedStairSweepArrow`).
|
||||
// Height / rise arrows from the 3D set don't translate — no vertical axis
|
||||
// in the plan view.
|
||||
floorplanAffordances: {
|
||||
'segment-width': segmentWidthAffordance,
|
||||
'segment-length': segmentLengthAffordance,
|
||||
'curved-width': curvedStairWidthAffordance,
|
||||
'curved-inner-radius': curvedStairInnerRadiusAffordance,
|
||||
'curved-sweep': curvedStairSweepAffordance,
|
||||
'stair-rotate': stairRotateAffordance,
|
||||
},
|
||||
|
||||
presentation: {
|
||||
label: 'Stair',
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
// Minimums + max sweep mirror the 3D handles in
|
||||
// `packages/editor/src/components/editor/stair-segment-handles.tsx` so a 2D
|
||||
// drag can't push a stair past what the 3D drag would allow.
|
||||
const MIN_SEGMENT_WIDTH = 0.4
|
||||
const MIN_SEGMENT_LENGTH = 0.4
|
||||
const MIN_CURVED_WIDTH = 0.4
|
||||
const MIN_CURVED_INNER_RADIUS_SPIRAL = 0.05
|
||||
const MIN_CURVED_INNER_RADIUS_CURVED = 0.2
|
||||
const MIN_CURVED_SWEEP = Math.PI / 12
|
||||
const MAX_CURVED_SWEEP = Math.PI * 2 - 0.05
|
||||
|
||||
type SegmentWidthPayload = {
|
||||
segmentId: string
|
||||
side: 'left' | 'right'
|
||||
axisX: readonly [number, number]
|
||||
}
|
||||
|
||||
type SegmentLengthPayload = {
|
||||
segmentId: string
|
||||
axisZ: readonly [number, number]
|
||||
}
|
||||
|
||||
type CurvedSweepPayload = {
|
||||
end: 'start' | 'end'
|
||||
}
|
||||
|
||||
function noopSession(): FloorplanAffordanceSession {
|
||||
return {
|
||||
affectedIds: [],
|
||||
apply() {},
|
||||
canCommit() {
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Straight-stair segment side arrow → segment `width`. Sister to the 3D
|
||||
* `StairSegmentSideArrow` width drag (~line 235 of stair-segment-handles.tsx).
|
||||
* Width grows symmetrically around the segment centerline — the chain
|
||||
* rebuilds from the segment's `width` field, no opposite-edge anchor write
|
||||
* required. `axisX` is the segment-local +X axis in plan coords, captured
|
||||
* at emit-time so the projection stays valid through the drag.
|
||||
*/
|
||||
export const segmentWidthAffordance: FloorplanAffordance<StairNode> = {
|
||||
start({ payload, nodes, initialPlanPoint }) {
|
||||
const { segmentId, side, axisX } = payload as SegmentWidthPayload
|
||||
const segmentNodeId = segmentId as AnyNodeId
|
||||
const segment = nodes[segmentNodeId] as StairSegmentNode | undefined
|
||||
if (!segment || segment.type !== 'stair-segment') return noopSession()
|
||||
|
||||
const initialWidth = segment.width
|
||||
const sign = side === 'right' ? 1 : -1
|
||||
const ax = axisX[0]
|
||||
const ay = axisX[1]
|
||||
const initialProj = initialPlanPoint[0] * ax + initialPlanPoint[1] * ay
|
||||
let lastWidth = initialWidth
|
||||
|
||||
return {
|
||||
affectedIds: [segmentNodeId],
|
||||
apply({ planPoint }) {
|
||||
const currentProj = planPoint[0] * ax + planPoint[1] * ay
|
||||
const delta = sign * (currentProj - initialProj)
|
||||
const newWidth = Math.max(MIN_SEGMENT_WIDTH, initialWidth + delta)
|
||||
lastWidth = newWidth
|
||||
useScene.getState().updateNode(segmentNodeId, { width: newWidth })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(segmentNodeId, { width: lastWidth })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Straight-stair segment length arrow → segment `length`. Sister to the 3D
|
||||
* `StairSegmentLengthArrow` drag. The chain anchors each segment's back
|
||||
* face, so length simply extends/contracts the front. `axisZ` is the
|
||||
* segment-local +Z (run) direction in plan coords.
|
||||
*/
|
||||
export const segmentLengthAffordance: FloorplanAffordance<StairNode> = {
|
||||
start({ payload, nodes, initialPlanPoint }) {
|
||||
const { segmentId, axisZ } = payload as SegmentLengthPayload
|
||||
const segmentNodeId = segmentId as AnyNodeId
|
||||
const segment = nodes[segmentNodeId] as StairSegmentNode | undefined
|
||||
if (!segment || segment.type !== 'stair-segment') return noopSession()
|
||||
|
||||
const initialLength = segment.length
|
||||
const az = axisZ[0]
|
||||
const ay = axisZ[1]
|
||||
const initialProj = initialPlanPoint[0] * az + initialPlanPoint[1] * ay
|
||||
let lastLength = initialLength
|
||||
|
||||
return {
|
||||
affectedIds: [segmentNodeId],
|
||||
apply({ planPoint }) {
|
||||
const currentProj = planPoint[0] * az + planPoint[1] * ay
|
||||
const delta = currentProj - initialProj
|
||||
const newLength = Math.max(MIN_SEGMENT_LENGTH, initialLength + delta)
|
||||
lastLength = newLength
|
||||
useScene.getState().updateNode(segmentNodeId, { length: newLength })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(segmentNodeId, { length: lastLength })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Curved / spiral width arrow → stair `width`. Drag radially outward grows
|
||||
* the body, anchored at the inner radius (matches the 3D
|
||||
* `CurvedStairWidthArrow`). The sweep bisector matches the existing
|
||||
* floor-plan emitter (`sectorStartAngle = -rotation - sweep/2`, bisector
|
||||
* = -rotation).
|
||||
*/
|
||||
export const curvedStairWidthAffordance: FloorplanAffordance<StairNode> = {
|
||||
start({ node, initialPlanPoint }) {
|
||||
const stairId = node.id as AnyNodeId
|
||||
const initialWidth = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
|
||||
const midAngle = -node.rotation
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
const radialX = Math.cos(midAngle)
|
||||
const radialZ = Math.sin(midAngle)
|
||||
const initialRadial =
|
||||
(initialPlanPoint[0] - cx) * radialX + (initialPlanPoint[1] - cz) * radialZ
|
||||
let lastWidth = initialWidth
|
||||
|
||||
return {
|
||||
affectedIds: [stairId],
|
||||
apply({ planPoint }) {
|
||||
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
|
||||
const newWidth = Math.max(
|
||||
MIN_CURVED_WIDTH,
|
||||
initialWidth + (currentRadial - initialRadial),
|
||||
)
|
||||
lastWidth = newWidth
|
||||
useScene.getState().updateNode(stairId, { width: newWidth })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(stairId, { width: lastWidth })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Curved / spiral inner-radius arrow → stair `innerRadius` + `width`. Keeps
|
||||
* the outer edge pinned (width absorbs the radial delta) so dragging only
|
||||
* shifts the inner rim, matching the 3D `CurvedStairInnerRadiusArrow`.
|
||||
*/
|
||||
export const curvedStairInnerRadiusAffordance: FloorplanAffordance<StairNode> = {
|
||||
start({ node, initialPlanPoint }) {
|
||||
const stairId = node.id as AnyNodeId
|
||||
const isSpiral = node.stairType === 'spiral'
|
||||
const minInnerRadius = isSpiral ? MIN_CURVED_INNER_RADIUS_SPIRAL : MIN_CURVED_INNER_RADIUS_CURVED
|
||||
const initialInnerRadius = Math.max(minInnerRadius, node.innerRadius ?? 0.9)
|
||||
const initialWidth = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
|
||||
const initialOuterRadius = initialInnerRadius + initialWidth
|
||||
const maxInnerRadius = initialOuterRadius - MIN_CURVED_WIDTH
|
||||
|
||||
const midAngle = -node.rotation
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
const radialX = Math.cos(midAngle)
|
||||
const radialZ = Math.sin(midAngle)
|
||||
const initialRadial =
|
||||
(initialPlanPoint[0] - cx) * radialX + (initialPlanPoint[1] - cz) * radialZ
|
||||
let lastInner = initialInnerRadius
|
||||
let lastWidth = initialWidth
|
||||
|
||||
return {
|
||||
affectedIds: [stairId],
|
||||
apply({ planPoint }) {
|
||||
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
|
||||
const innerDelta = currentRadial - initialRadial
|
||||
const newInner = Math.min(
|
||||
maxInnerRadius,
|
||||
Math.max(minInnerRadius, initialInnerRadius + innerDelta),
|
||||
)
|
||||
const newWidth = initialOuterRadius - newInner
|
||||
lastInner = newInner
|
||||
lastWidth = newWidth
|
||||
useScene.getState().updateNode(stairId, { innerRadius: newInner, width: newWidth })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(stairId, { innerRadius: lastInner, width: lastWidth })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-stair rotation gizmo → stair `rotation`. Mirrors the 3D
|
||||
* `stairRotateHandle` (arc-resize, curved-arrow shape). Angular drag
|
||||
* around the stair's plan-space pivot — atan2 ticks CW visually, the
|
||||
* stored `rotation` field is the schema's Y-axis radians, and the
|
||||
* floorplan plots sectors at `-rotation`, so a positive cursor delta
|
||||
* (CCW around the centre in standard math coords / CW on screen given
|
||||
* inverted Y) should DECREASE `rotation`. Same `- delta` convention the
|
||||
* 3D handle uses; cursor handedness across both views matches.
|
||||
*/
|
||||
export const stairRotateAffordance: FloorplanAffordance<StairNode> = {
|
||||
start({ node, initialPlanPoint }) {
|
||||
const stairId = node.id as AnyNodeId
|
||||
const initialRotation = node.rotation ?? 0
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
|
||||
let lastRotation = initialRotation
|
||||
|
||||
return {
|
||||
affectedIds: [stairId],
|
||||
apply({ planPoint }) {
|
||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
||||
let delta = currentAngle - initialAngle
|
||||
// Wrap to [-π, π] so a drag crossing ±π doesn't flip sign mid-gesture.
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
const newRotation = initialRotation - delta
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(stairId, { rotation: newRotation })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(stairId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Curved / spiral sweep arrows → stair `sweepAngle` + `rotation`. Anchors
|
||||
* the opposite edge world-fixed by nudging `rotation` by half the applied
|
||||
* sweep delta — mirror of the 3D `CurvedStairSweepArrow`. Sign math derives
|
||||
* from the floorplan convention `sectorStartAngle = -rotation - sweep/2`:
|
||||
*
|
||||
* END handle (sweep += Δ, fix start): ΔR = -Δ/2
|
||||
* START handle (sweep -= Δ, fix end): ΔR = +Δ/2
|
||||
*/
|
||||
export const curvedStairSweepAffordance: FloorplanAffordance<StairNode> = {
|
||||
start({ node, payload, initialPlanPoint }) {
|
||||
const { end } = payload as CurvedSweepPayload
|
||||
const stairId = node.id as AnyNodeId
|
||||
const initialSweep =
|
||||
node.sweepAngle ?? (node.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
|
||||
const sweepSign = Math.sign(initialSweep) || 1
|
||||
const initialRotation = node.rotation
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
|
||||
let lastSweep = initialSweep
|
||||
let lastRotation = initialRotation
|
||||
|
||||
return {
|
||||
affectedIds: [stairId],
|
||||
apply({ planPoint }) {
|
||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
||||
let delta = currentAngle - initialAngle
|
||||
// Wrap to [-π, π] so a drag crossing ±π doesn't flip sign mid-gesture.
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
|
||||
const sweepDelta = end === 'end' ? delta : -delta
|
||||
const targetSweep = initialSweep + sweepDelta
|
||||
const clampedAbs = Math.min(
|
||||
MAX_CURVED_SWEEP,
|
||||
Math.max(MIN_CURVED_SWEEP, Math.abs(targetSweep)),
|
||||
)
|
||||
const newSweep = sweepSign * clampedAbs
|
||||
const appliedDelta = newSweep - initialSweep
|
||||
const rotationShift = end === 'end' ? -appliedDelta / 2 : appliedDelta / 2
|
||||
const newRotation = initialRotation + rotationShift
|
||||
lastSweep = newSweep
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(stairId, { sweepAngle: newSweep, rotation: newRotation })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(stairId, { sweepAngle: lastSweep, rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
type StairNode,
|
||||
snapScalar,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getSegmentGridStep } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for stair — kicks in when the user clicks
|
||||
* "Move" on the stair action menu and the floor-plan view is active.
|
||||
*
|
||||
* **Delta-based motion, anchored on first pointermove.** The first
|
||||
* `apply` only captures `rawAnchor` — the cursor's pointer position the
|
||||
* instant the move begins — and skips writing to the scene. Subsequent
|
||||
* applies translate the stair's original position by the cursor's raw
|
||||
* delta and then snap the *absolute* result to the 0.5 m grid.
|
||||
*
|
||||
* Anchoring matters because the action-menu Move button portals to
|
||||
* `document.body`, so the move starts with the cursor wherever the menu
|
||||
* sits (often nowhere near the stair). The previous "position = snapped
|
||||
* cursor" implementation made the stair teleport to the menu's screen
|
||||
* position on the very first pointermove, which is the "drag doesn't
|
||||
* happen properly" symptom. Mirrors the same anchor pattern wall's
|
||||
* `floorplan-move.ts` uses.
|
||||
*
|
||||
* Snapping the absolute position (rather than the delta) keeps the
|
||||
* stair on the same 0.5 m grid the 3D StairTool placement and 3D
|
||||
* MoveRegistryNodeTool use — so dragging in 2D lands at the same
|
||||
* grid intersections you'd hit dragging in 3D.
|
||||
*
|
||||
* Routing through `floorplanMoveTarget` (instead of the overlay's
|
||||
* generic Path 2 translate) fixes two latent bugs the generic path
|
||||
* has for stair:
|
||||
*
|
||||
* 1. Path 2's `onPointerUp` bails when `event.target.closest(
|
||||
* '[data-floorplan-scene]')` fails — which happens when the
|
||||
* pointer-up lands on empty grid background. Path 1 uses the
|
||||
* overlay's bounding-rect check, which accepts any pointer
|
||||
* inside the SVG viewport.
|
||||
* 2. Path 2 commits via a single `updateNode` call that has no
|
||||
* "self-owned commit" hook, so the overlay's diff path can
|
||||
* silently revert when the final state matches the snapshot.
|
||||
* The `commit()` below mirrors door's pattern: take ownership
|
||||
* of the atomic write so the deterministic
|
||||
* revert → resume → `session.commit()` path runs.
|
||||
*/
|
||||
export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node }) => {
|
||||
// Capture the stair's original position once — apply() reads these
|
||||
// every tick instead of re-querying scene state (which would
|
||||
// double-apply our own writes).
|
||||
const originalX = node.position[0]
|
||||
const startY = node.position[1]
|
||||
const originalZ = node.position[2]
|
||||
|
||||
let rawAnchor: [number, number] | null = null
|
||||
let lastValid: { position: [number, number, number] } | null = null
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.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]
|
||||
// Snap the absolute new position to the editor's current grid
|
||||
// step (the same one the cursor / draft snap to — driven by
|
||||
// `useEditor.gridSnapStep`). Hardcoding 0.5 here caused the stair
|
||||
// to snap to half-metre cells even when the user had set the grid
|
||||
// to a finer step like 0.1, so the cursor and the stair SVG
|
||||
// landed at different grid points. Shift bypasses snap entirely.
|
||||
const step = getSegmentGridStep()
|
||||
const rawX = originalX + rawDx
|
||||
const rawZ = originalZ + rawDz
|
||||
const sx = modifiers.shiftKey ? rawX : snapScalar(rawX, step)
|
||||
const sz = modifiers.shiftKey ? rawZ : snapScalar(rawZ, step)
|
||||
|
||||
if (lastValid && lastValid.position[0] === sx && lastValid.position[2] === sz) return
|
||||
lastValid = { position: [sx, startY, sz] }
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
|
||||
},
|
||||
canCommit() {
|
||||
// No overlap / placement rules for stairs in 2D — any pointer-up
|
||||
// position commits, as long as we actually moved. Mirrors the 3D
|
||||
// move tool which also lets stairs land anywhere on the slab.
|
||||
return lastValid !== null
|
||||
},
|
||||
commit() {
|
||||
// Own the atomic write so the overlay takes the deterministic
|
||||
// commit-path (revert → resume → session.commit()). The dispatcher's
|
||||
// diff path would otherwise re-derive the final state by comparing
|
||||
// the post-apply scene to the snapshot — which produces an empty
|
||||
// diff (and silent revert) when the committed move happens to have
|
||||
// identical key/value pairs to the snapshot. Owning commit removes
|
||||
// that foot-gun. Same pattern door / window use.
|
||||
if (!lastValid) return
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
|
||||
},
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
@@ -5,6 +5,12 @@ import type {
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
// Offset from the stair's footprint edge to the rotation chevron's
|
||||
// origin. Same magnitude as `STAIR_ROTATE_CORNER_OFFSET` in
|
||||
// `definition.ts` so the 2D handle visually lines up with where the 3D
|
||||
// curved-arrow gizmo would sit at the matching world point.
|
||||
const STAIR_ROTATE_PLAN_OFFSET = 0.4
|
||||
import {
|
||||
buildFloorplanStairEntry,
|
||||
buildSvgAnnularSectorPath,
|
||||
@@ -107,6 +113,79 @@ export function buildStairFloorplan(
|
||||
opacity: showSelectedChrome ? 0.88 : 0.6,
|
||||
})
|
||||
}
|
||||
|
||||
// Per-segment side + length resize arrows. Mirror of the 3D
|
||||
// `StairSegmentSideArrow` / `StairSegmentLengthArrow` handles
|
||||
// (~lines 235 / 375 of stair-segment-handles.tsx).
|
||||
// Skip when the stair is being placed — placement-mode arrows would
|
||||
// compete with the cursor follow.
|
||||
if (isSelected && !view?.moving) {
|
||||
const poly = segmentEntry.polygon
|
||||
// Polygon corners (from `getFloorplanStairSegmentPolygon`):
|
||||
// 0 back-left 1 back-right
|
||||
// 3 front-left 2 front-right
|
||||
const c0 = poly[0]
|
||||
const c1 = poly[1]
|
||||
const c2 = poly[2]
|
||||
const c3 = poly[3]
|
||||
if (c0 && c1 && c2 && c3) {
|
||||
const width = segmentEntry.segment.width || 1
|
||||
const length = segmentEntry.segment.length || 1
|
||||
// Segment-local +X (width axis) and +Z (run axis) in plan coords,
|
||||
// captured here so the affordance handler can project pointer
|
||||
// deltas without re-walking the stair chain.
|
||||
const axisX: readonly [number, number] = [
|
||||
(c1.x - c0.x) / width,
|
||||
(c1.y - c0.y) / width,
|
||||
]
|
||||
const axisZ: readonly [number, number] = [
|
||||
(c3.x - c0.x) / length,
|
||||
(c3.y - c0.y) / length,
|
||||
]
|
||||
const rightMid: [number, number] = [(c1.x + c2.x) / 2, (c1.y + c2.y) / 2]
|
||||
const leftMid: [number, number] = [(c0.x + c3.x) / 2, (c0.y + c3.y) / 2]
|
||||
const frontEdgeMid: [number, number] = [(c2.x + c3.x) / 2, (c2.y + c3.y) / 2]
|
||||
// Offset the length arrow's base OUT past the front edge so the
|
||||
// shaft+head sit entirely beyond the stair body. The arrow path's
|
||||
// own `bi` inset is only 0.03 m — short enough that the head can
|
||||
// still overlap the stair fill at common zooms, which reads as
|
||||
// "the arrow is lying along the edge / pointing sideways" instead
|
||||
// of clearly pointing forward off the run. Pushing the anchor
|
||||
// along +axisZ removes that ambiguity.
|
||||
const segmentLengthArrowOffset = 0.06
|
||||
const frontArrowAnchor: [number, number] = [
|
||||
frontEdgeMid[0] + axisZ[0] * segmentLengthArrowOffset,
|
||||
frontEdgeMid[1] + axisZ[1] * segmentLengthArrowOffset,
|
||||
]
|
||||
const segmentId = segmentEntry.segment.id
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: rightMid,
|
||||
angle: Math.atan2(axisX[1], axisX[0]),
|
||||
affordance: 'segment-width',
|
||||
payload: { segmentId, side: 'right', axisX },
|
||||
})
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: leftMid,
|
||||
angle: Math.atan2(-axisX[1], -axisX[0]),
|
||||
affordance: 'segment-width',
|
||||
payload: { segmentId, side: 'left', axisX },
|
||||
})
|
||||
// Length arrow — anchored just past the front edge, pointing in
|
||||
// the segment's run direction (axisZ = back-to-front). After the
|
||||
// SVG `rotate(angle)`, the arrow's local +X (its tip) lines up
|
||||
// with +axisZ, so the head clearly extends forward off the front
|
||||
// edge instead of sideways across it.
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: frontArrowAnchor,
|
||||
angle: Math.atan2(axisZ[1], axisZ[0]),
|
||||
affordance: 'segment-length',
|
||||
payload: { segmentId, axisZ },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Curved / spiral — full arc-band chrome. Mirrors the legacy
|
||||
@@ -116,7 +195,19 @@ export function buildStairFloorplan(
|
||||
const sectorStartAngle = -stair.rotation - normalizedSweepAngle / 2
|
||||
const sectorEndAngle = sectorStartAngle + normalizedSweepAngle
|
||||
const spiralLandingSweep = getFloorplanSpiralLandingSweep(stair, normalizedSweepAngle)
|
||||
const visualSectorEndAngle = sectorEndAngle + spiralLandingSweep
|
||||
// SVG `A` (arc) draws a single sub-360° segment. Once the base sweep
|
||||
// is near a full turn and we add up to 0.75π of integrated landing
|
||||
// on top, the total `visualSectorEnd - sectorStart` overflows 2π and
|
||||
// the path becomes malformed (the whole stair chrome breaks). Cap
|
||||
// the COMBINED visual sweep to just under a full revolution — past
|
||||
// that, the landing visually overlaps the start of the arc, which
|
||||
// is exactly the multi-turn "stack on top of each other" behaviour
|
||||
// we want for spirals with > 360° rotation.
|
||||
const rawVisualSweep = normalizedSweepAngle + spiralLandingSweep
|
||||
const sweepCap = Math.PI * 2 - 0.001
|
||||
const visualSweep =
|
||||
Math.sign(rawVisualSweep || 1) * Math.min(Math.abs(rawVisualSweep), sweepCap)
|
||||
const visualSectorEndAngle = sectorStartAngle + visualSweep
|
||||
const stairCenter = { x: stair.position[0], y: stair.position[2] }
|
||||
const innerRadius = Math.max(
|
||||
stairType === 'spiral' ? 0.05 : 0.2,
|
||||
@@ -125,6 +216,13 @@ export function buildStairFloorplan(
|
||||
const outerRadius = innerRadius + stair.width
|
||||
const centerlineRadius = innerRadius + stair.width / 2
|
||||
|
||||
// Stroke widths are screen pixels (paired with `vectorEffect:
|
||||
// 'non-scaling-stroke'` below). World-metre values like 0.02 would
|
||||
// render as sub-pixel — invisible at every zoom. Matches the legacy
|
||||
// `<FloorplanStairLayer>` curved/spiral branches.
|
||||
const outerArcWidth = showSelectedChrome ? 2 : 1.4
|
||||
const innerArcWidth = showSelectedChrome ? 1.7 : 1.2
|
||||
|
||||
// 1. Annular sector — the filled shaft footprint.
|
||||
children.push({
|
||||
kind: 'path',
|
||||
@@ -147,7 +245,7 @@ export function buildStairFloorplan(
|
||||
d: buildSvgArcPath(stairCenter, outerRadius, sectorStartAngle, visualSectorEndAngle),
|
||||
fill: 'none',
|
||||
stroke: stairStroke,
|
||||
strokeWidth: showSelectedChrome ? 0.026 : 0.022,
|
||||
strokeWidth: outerArcWidth,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
children.push({
|
||||
@@ -155,7 +253,7 @@ export function buildStairFloorplan(
|
||||
d: buildSvgArcPath(stairCenter, innerRadius, sectorStartAngle, visualSectorEndAngle),
|
||||
fill: 'none',
|
||||
stroke: stairStroke,
|
||||
strokeWidth: showSelectedChrome ? 0.022 : 0.018,
|
||||
strokeWidth: innerArcWidth,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
|
||||
@@ -171,14 +269,29 @@ export function buildStairFloorplan(
|
||||
const inner = getArcPlanPoint(stairCenter, innerRadius, angle)
|
||||
const outer = getArcPlanPoint(stairCenter, outerRadius, angle)
|
||||
const isLast = index === stepCount
|
||||
const isFirst = index === 0
|
||||
// Curved: regular stroke everywhere, but both the starting and the
|
||||
// ending step lines are bolded (matches the legacy
|
||||
// `<FloorplanStairLayer>` curved branch).
|
||||
// Spiral: only the last step is accented + bolded; intermediate
|
||||
// steps past `dashedFromIndex` are dashed.
|
||||
const isEmphasised = stairType === 'spiral' ? isLast : isFirst || isLast
|
||||
const stepWidth =
|
||||
stairType === 'spiral'
|
||||
? isEmphasised
|
||||
? 1.8
|
||||
: 1.15
|
||||
: isEmphasised
|
||||
? 1.5
|
||||
: 1.1
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: inner.x,
|
||||
y1: inner.y,
|
||||
x2: outer.x,
|
||||
y2: outer.y,
|
||||
stroke: isLast ? stairAccent : stairStroke,
|
||||
strokeWidth: isLast ? 0.026 : 0.018,
|
||||
stroke: stairType === 'spiral' && isLast ? stairAccent : stairStroke,
|
||||
strokeWidth: stepWidth,
|
||||
strokeDasharray: index >= dashedFromIndex && !isLast ? '0.1 0.08' : undefined,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
@@ -199,7 +312,7 @@ export function buildStairFloorplan(
|
||||
fill: 'none',
|
||||
stroke: stairAccent,
|
||||
strokeDasharray: '0.08 0.11',
|
||||
strokeWidth: 0.018,
|
||||
strokeWidth: 1.1,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
}
|
||||
@@ -214,7 +327,7 @@ export function buildStairFloorplan(
|
||||
r: Math.max(innerRadius * 0.18, 0.06),
|
||||
fill,
|
||||
stroke: stairAccent,
|
||||
strokeWidth: 0.018,
|
||||
strokeWidth: 1.2,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
}
|
||||
@@ -231,12 +344,70 @@ export function buildStairFloorplan(
|
||||
fill: stairAccent,
|
||||
stroke: 'none',
|
||||
})
|
||||
|
||||
// 7. Resize arrows — mirror of the 3D `CurvedStairWidthArrow`,
|
||||
// `CurvedStairInnerRadiusArrow`, and two `CurvedStairSweepArrow`s.
|
||||
// Hidden during placement (`view?.moving`) so they don't fight the
|
||||
// cursor follow.
|
||||
if (isSelected && !view?.moving) {
|
||||
const midAngle = (sectorStartAngle + sectorEndAngle) / 2
|
||||
const sweepSign = Math.sign(normalizedSweepAngle) || 1
|
||||
// Width arrow — radially outward at the sweep bisector, on the outer rim.
|
||||
const widthAnchor = getArcPlanPoint(stairCenter, outerRadius, midAngle)
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [widthAnchor.x, widthAnchor.y],
|
||||
angle: midAngle,
|
||||
affordance: 'curved-width',
|
||||
payload: { kind: 'width' },
|
||||
})
|
||||
|
||||
// Inner-radius arrow — just inside the inner edge, chevron pointing
|
||||
// toward the centre. Skip for very tight spirals where there's no
|
||||
// room (chevron would tunnel through the central column).
|
||||
if (innerRadius > 0.18) {
|
||||
const innerArrowRadius = Math.max(innerRadius - 0.04, innerRadius * 0.45)
|
||||
const innerAnchor = getArcPlanPoint(stairCenter, innerArrowRadius, midAngle)
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [innerAnchor.x, innerAnchor.y],
|
||||
angle: midAngle + Math.PI,
|
||||
affordance: 'curved-inner-radius',
|
||||
payload: { kind: 'inner-radius' },
|
||||
})
|
||||
}
|
||||
|
||||
// Sweep arrows — anchored at the actual sweep ends on the outer rim,
|
||||
// chevrons pointing tangentially in the grow direction. (3D clusters
|
||||
// them next to the width arrow because the camera-facing rim is
|
||||
// easier to grab; in plan we have the whole arc visible, so the
|
||||
// ends are the natural placement.)
|
||||
const sweepEndAnchor = getArcPlanPoint(stairCenter, outerRadius, sectorEndAngle)
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [sweepEndAnchor.x, sweepEndAnchor.y],
|
||||
angle: sectorEndAngle + sweepSign * (Math.PI / 2),
|
||||
affordance: 'curved-sweep',
|
||||
payload: { end: 'end' },
|
||||
})
|
||||
const sweepStartAnchor = getArcPlanPoint(stairCenter, outerRadius, sectorStartAngle)
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [sweepStartAnchor.x, sweepStartAnchor.y],
|
||||
angle: sectorStartAngle - sweepSign * (Math.PI / 2),
|
||||
affordance: 'curved-sweep',
|
||||
payload: { end: 'start' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Direction arrow — emitted by `buildFloorplanStairEntry` as a polyline
|
||||
// (the spine) plus a polygon (the head). Tells the user which way
|
||||
// "up" is at a glance.
|
||||
if (entry.arrow) {
|
||||
// "up" is at a glance. Skip for curved / spiral: those already draw
|
||||
// their own arc-aligned arrow above; `buildFloorplanStairArrow` traces
|
||||
// the stair-segment chain in straight space and produces a malformed
|
||||
// polyline once the chain is laid around an arc.
|
||||
if (stairType === 'straight' && entry.arrow) {
|
||||
if (entry.arrow.polyline.length >= 2) {
|
||||
children.push({
|
||||
kind: 'polyline',
|
||||
@@ -260,6 +431,56 @@ export function buildStairFloorplan(
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-stair rotation handle — sister to the 3D `stairRotateHandle`
|
||||
// (arc-resize, curved-arrow). 2D doesn't have a dedicated curved-arrow
|
||||
// primitive, so we emit a `move-arrow` with the `'stair-rotate'`
|
||||
// affordance: the chevron sits at the stair's outer corner and a drag
|
||||
// around the stair centre rotates the whole node. Placement mirrors
|
||||
// the 3D handle:
|
||||
// - straight: at the +X / -Z corner of the run start
|
||||
// - curved / spiral: outer rim at the sweep-start side
|
||||
// Position is computed in stair-local coords then rotated into plan
|
||||
// coords by `R(-θ)` — matches the convention the curved sector emitter
|
||||
// already uses (`sectorStartAngle = -stair.rotation - sweep/2`).
|
||||
if (isSelected && !view?.moving) {
|
||||
const cos = Math.cos(stair.rotation)
|
||||
const sin = Math.sin(stair.rotation)
|
||||
const cx = stair.position[0]
|
||||
const cz = stair.position[2]
|
||||
let localX: number
|
||||
let localZ: number
|
||||
if (stairType === 'straight') {
|
||||
const stairWidth = Math.max(stair.width ?? 1, 0.4)
|
||||
localX = stairWidth / 2 + STAIR_ROTATE_PLAN_OFFSET
|
||||
localZ = -STAIR_ROTATE_PLAN_OFFSET
|
||||
} else {
|
||||
const isSpiral = stairType === 'spiral'
|
||||
const innerR = Math.max(
|
||||
isSpiral ? 0.05 : 0.2,
|
||||
stair.innerRadius ?? (isSpiral ? 0.2 : 0.9),
|
||||
)
|
||||
const outerR = innerR + (stair.width ?? 1)
|
||||
const sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
||||
const radius = outerR + STAIR_ROTATE_PLAN_OFFSET
|
||||
const localAngle = -sweep / 2
|
||||
localX = radius * Math.cos(localAngle)
|
||||
localZ = radius * Math.sin(localAngle)
|
||||
}
|
||||
const planX = cx + localX * cos + localZ * sin
|
||||
const planY = cz - localX * sin + localZ * cos
|
||||
// The `rotate-arrow` icon is designed in a local frame where +X is
|
||||
// the radial-outward direction from the pivot. `angle` selects that
|
||||
// direction in plan coords; the arrowheads then read as tangential
|
||||
// motion around the stair centre.
|
||||
const radialAngle = Math.atan2(planY - cz, planX - cx)
|
||||
children.push({
|
||||
kind: 'rotate-arrow',
|
||||
point: [planX, planY],
|
||||
angle: radialAngle,
|
||||
affordance: 'stair-rotate',
|
||||
})
|
||||
}
|
||||
|
||||
// Move handle — orange dot at the stair root position. Same UX as
|
||||
// every other kind's `move-handle`: click to enter cursor-follow
|
||||
// mode, click again to commit.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -52,8 +53,18 @@ type LandingChainNextStair = {
|
||||
isTerminalLandingBeforeStair: boolean
|
||||
}
|
||||
|
||||
export const StairRenderer = ({ node }: { node: StairNode }) => {
|
||||
export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
// Merge any live drag override into the node so curved/spiral geometry
|
||||
// (built declaratively in JSX below) rebuilds on every drag tick. The
|
||||
// resize arrows publish to `useLiveNodeOverrides` and only commit to
|
||||
// zustand on release — subscribing here turns those override writes
|
||||
// into the React re-renders that drive the visible mesh update.
|
||||
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(rawNode.id))
|
||||
const node = useMemo<StairNode>(
|
||||
() => (liveOverride ? ({ ...rawNode, ...liveOverride } as StairNode) : rawNode),
|
||||
[rawNode, liveOverride],
|
||||
)
|
||||
const isSegmentBasedStair = node.stairType === 'straight'
|
||||
|
||||
useRegistry(node.id, 'stair', ref)
|
||||
@@ -464,17 +475,11 @@ function CurvedStairBody({
|
||||
return (
|
||||
<group name={isSpiral ? 'spiral-stair' : 'curved-stair'}>
|
||||
{isSpiral && (stair.showCenterColumn ?? true) ? (
|
||||
<mesh
|
||||
castShadow
|
||||
<SpiralColumnMesh
|
||||
height={spiralColumnHeight}
|
||||
material={sideMaterial}
|
||||
name="stair-side"
|
||||
position={[0, spiralColumnHeight / 2, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<cylinderGeometry
|
||||
args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]}
|
||||
/>
|
||||
</mesh>
|
||||
radius={spiralColumnRadius}
|
||||
/>
|
||||
) : null}
|
||||
{Array.from({ length: stepCount }).map((_, index) => {
|
||||
const currentHeight = stepHeight * (index + 1)
|
||||
@@ -498,32 +503,13 @@ function CurvedStairBody({
|
||||
position-y={stepY}
|
||||
>
|
||||
{isSpiral && (stair.showStepSupports ?? true) ? (
|
||||
<mesh
|
||||
castShadow
|
||||
<SpiralStepSupportMesh
|
||||
innerRadius={innerRadius}
|
||||
material={sideMaterial}
|
||||
name="stair-side"
|
||||
position={[
|
||||
Math.cos(midAngle) *
|
||||
(spiralColumnRadius +
|
||||
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 -
|
||||
0.02),
|
||||
Math.max(thickness * 0.55, 0.025) / 2,
|
||||
Math.sin(midAngle) *
|
||||
(spiralColumnRadius +
|
||||
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 -
|
||||
0.02),
|
||||
]}
|
||||
receiveShadow
|
||||
rotation-y={-midAngle}
|
||||
>
|
||||
<boxGeometry
|
||||
args={[
|
||||
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04),
|
||||
Math.max(thickness * 0.55, 0.025),
|
||||
Math.max(0.04, Math.min(0.12, Math.max(thickness * 0.55, 0.025) * 1.5)),
|
||||
]}
|
||||
/>
|
||||
</mesh>
|
||||
midAngle={midAngle}
|
||||
spiralColumnRadius={spiralColumnRadius}
|
||||
thickness={thickness}
|
||||
/>
|
||||
) : null}
|
||||
<CurvedStepMesh
|
||||
endAngle={endAngle}
|
||||
@@ -585,11 +571,110 @@ function CurvedStepMesh({
|
||||
[endAngle, innerRadius, outerRadius, startAngle, stepHeight, thickness],
|
||||
)
|
||||
|
||||
// Dispose the prior BufferGeometry as soon as a new one supersedes it.
|
||||
// Resize drags (in 2D or 3D) rebuild this geometry every pointer move;
|
||||
// without explicit disposal, WebGPU keeps a stale pipeline reference to
|
||||
// the old vertex buffer and flags "Vertex buffer slot 0 required by
|
||||
// [RenderPipeline ...MeshLambertNodeMaterial...] was not set" on the
|
||||
// submit after the swap. Same mitigation as guide/renderer.tsx.
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose()
|
||||
},
|
||||
[geometry],
|
||||
)
|
||||
|
||||
return (
|
||||
<mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow />
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spiral center column. The cylinder is rebuilt whenever
|
||||
* `spiralColumnRadius` changes — i.e. on every tick of an inner-radius
|
||||
* drag. We pass the geometry as a prop (avoiding R3F's empty-placeholder
|
||||
* frame from inline JSX) and dispose the prior one on swap, matching the
|
||||
* pattern in guide/renderer.tsx. Without this WebGPU flags
|
||||
* "Vertex buffer slot 0 ... was not set" on Lambert mid-resize.
|
||||
*/
|
||||
function SpiralColumnMesh({
|
||||
radius,
|
||||
height,
|
||||
material,
|
||||
}: {
|
||||
radius: number
|
||||
height: number
|
||||
material: THREE.Material | THREE.Material[]
|
||||
}) {
|
||||
const geometry = useMemo(
|
||||
() => new THREE.CylinderGeometry(radius, radius, height, 10),
|
||||
[radius, height],
|
||||
)
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose()
|
||||
},
|
||||
[geometry],
|
||||
)
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={geometry}
|
||||
material={material}
|
||||
name="stair-side"
|
||||
position={[0, height / 2, 0]}
|
||||
receiveShadow
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spiral step support — the small box wedged between the column and the
|
||||
* inner rim of each step. Same prop-+-dispose pattern as
|
||||
* `SpiralColumnMesh`: the box dimensions change every inner-radius tick
|
||||
* (`innerRadius - spiralColumnRadius`), so inline-JSX geometry would
|
||||
* trigger the Lambert vertex-buffer error.
|
||||
*/
|
||||
function SpiralStepSupportMesh({
|
||||
innerRadius,
|
||||
spiralColumnRadius,
|
||||
midAngle,
|
||||
thickness,
|
||||
material,
|
||||
}: {
|
||||
innerRadius: number
|
||||
spiralColumnRadius: number
|
||||
midAngle: number
|
||||
thickness: number
|
||||
material: THREE.Material | THREE.Material[]
|
||||
}) {
|
||||
const sizeX = Math.max(0.04, innerRadius - spiralColumnRadius + 0.04)
|
||||
const sizeY = Math.max(thickness * 0.55, 0.025)
|
||||
const sizeZ = Math.max(0.04, Math.min(0.12, sizeY * 1.5))
|
||||
const geometry = useMemo(
|
||||
() => new THREE.BoxGeometry(sizeX, sizeY, sizeZ),
|
||||
[sizeX, sizeY, sizeZ],
|
||||
)
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose()
|
||||
},
|
||||
[geometry],
|
||||
)
|
||||
const radial = spiralColumnRadius + sizeX / 2 - 0.02
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={geometry}
|
||||
material={material}
|
||||
name="stair-side"
|
||||
position={[Math.cos(midAngle) * radial, sizeY / 2, Math.sin(midAngle) * radial]}
|
||||
receiveShadow
|
||||
rotation-y={-midAngle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function buildCurvedStepGeometry(
|
||||
innerRadius: number,
|
||||
outerRadius: number,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,115 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type WallNode,
|
||||
type WindowNode as WindowNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { buildWindowFloorplan } from './floorplan'
|
||||
import { windowWidthAffordance } from './floorplan-affordances'
|
||||
import { windowFloorplanMoveTarget } from './floorplan-move'
|
||||
import { windowParametrics } from './parametrics'
|
||||
import { WindowNode } from './schema'
|
||||
|
||||
const SIDE_HANDLE_OFFSET = 0.24
|
||||
const HEIGHT_HANDLE_OFFSET = 0.24
|
||||
const MIN_WINDOW_HEIGHT = 0.3
|
||||
const MIN_WINDOW_WIDTH = 0.3
|
||||
|
||||
function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
||||
if (!w.wallId) return Number.POSITIVE_INFINITY
|
||||
const wall = scene.get(w.wallId as AnyNodeId) as WallNode | undefined
|
||||
if (!wall) return Number.POSITIVE_INFINITY
|
||||
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
}
|
||||
|
||||
function readWallHeight(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
||||
if (!w.wallId) return Number.POSITIVE_INFINITY
|
||||
const wall = scene.get(w.wallId as AnyNodeId) as WallNode | undefined
|
||||
return wall?.height ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor<WindowNodeType> {
|
||||
const sign = side === 'right' ? 1 : -1
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: side === 'right' ? 'min' : 'max',
|
||||
min: MIN_WINDOW_WIDTH,
|
||||
max: (n, scene) => readWallLength(n, scene),
|
||||
currentValue: (n) => n.width,
|
||||
apply: (initial, newWidth) => {
|
||||
const rotY = initial.rotation[1]
|
||||
const armX = Math.cos(rotY)
|
||||
const armZ = -Math.sin(rotY)
|
||||
const anchorX = initial.position[0] - sign * (initial.width / 2) * armX
|
||||
const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ
|
||||
return {
|
||||
width: newWidth,
|
||||
position: [
|
||||
anchorX + sign * (newWidth / 2) * armX,
|
||||
initial.position[1],
|
||||
anchorZ + sign * (newWidth / 2) * armZ,
|
||||
],
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n) => [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), 0, 0],
|
||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||
},
|
||||
portal: 'grandparent',
|
||||
}
|
||||
}
|
||||
|
||||
// Window height has two arrows (top + bottom edge) — each anchors at the
|
||||
// opposite edge so dragging the top grows the window upward and dragging
|
||||
// the bottom grows it downward without the opposite edge moving.
|
||||
function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNodeType> {
|
||||
const sign = edge === 'top' ? 1 : -1
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
// top arrow anchors at -Y (bottom stays fixed); bottom at +Y (top stays).
|
||||
anchor: edge === 'top' ? 'min' : 'max',
|
||||
min: MIN_WINDOW_HEIGHT,
|
||||
max: (n, scene) => {
|
||||
// Maximum: distance from the anchored edge to the wall's allowed Y
|
||||
// bounds. Top arrow caps at wall.height - bottom; bottom arrow caps
|
||||
// at top (positive Y room above the floor).
|
||||
const wallH = readWallHeight(n, scene)
|
||||
const anchored =
|
||||
edge === 'top' ? n.position[1] - n.height / 2 : n.position[1] + n.height / 2
|
||||
return edge === 'top'
|
||||
? Math.max(MIN_WINDOW_HEIGHT, wallH - anchored)
|
||||
: Math.max(MIN_WINDOW_HEIGHT, anchored)
|
||||
},
|
||||
currentValue: (n) => n.height,
|
||||
apply: (initial, newHeight) => {
|
||||
// Anchored edge stays in wall-local Y; opposite edge moves.
|
||||
const anchorY =
|
||||
edge === 'top'
|
||||
? initial.position[1] - initial.height / 2 // bottom anchored
|
||||
: initial.position[1] + initial.height / 2 // top anchored
|
||||
const newCenterY = anchorY + sign * (newHeight / 2)
|
||||
return {
|
||||
height: newHeight,
|
||||
position: [initial.position[0], newCenterY, initial.position[2]],
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n) => [0, sign * (n.height / 2 + HEIGHT_HANDLE_OFFSET), 0],
|
||||
},
|
||||
portal: 'grandparent',
|
||||
}
|
||||
}
|
||||
|
||||
const windowHandles: HandleDescriptor<WindowNodeType>[] = [
|
||||
windowWidthHandle('left'),
|
||||
windowWidthHandle('right'),
|
||||
windowHeightHandle('top'),
|
||||
windowHeightHandle('bottom'),
|
||||
]
|
||||
|
||||
/**
|
||||
* Window — Phase 5 batch kind. Mirrors door's shape: hosted on walls,
|
||||
* cuts holes in them, animated open/close state for opening windows.
|
||||
@@ -33,9 +139,11 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
wallOpeningPlacement: true,
|
||||
},
|
||||
|
||||
parametrics: windowParametrics,
|
||||
handles: windowHandles,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
@@ -57,6 +165,14 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
// 2D move-on-floorplan handler — same shape as door.
|
||||
floorplanMoveTarget: windowFloorplanMoveTarget,
|
||||
|
||||
// 2D drag affordances. `resize-width` drives the window's two side
|
||||
// arrows — pointer-down on either arrow starts an anchored width drag
|
||||
// (opposite edge stays fixed, clamped to wall bounds). Mirrors the
|
||||
// door wiring.
|
||||
floorplanAffordances: {
|
||||
'resize-width': windowWidthAffordance,
|
||||
},
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place window on wall' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
const MIN_WINDOW_WIDTH = 0.3
|
||||
|
||||
type WindowWidthPayload = { side: 'start' | 'end' }
|
||||
|
||||
/**
|
||||
* 2D drag affordance for the window's width side-arrows. Sister to the 3D
|
||||
* `WindowSideArrow` width drag in `packages/editor/src/components/editor/
|
||||
* window-side-handles.tsx` — both anchor at the opposite window edge and
|
||||
* clamp to wall bounds. Mirrors `doorWidthAffordance` 1:1 with the door
|
||||
* type swapped for the window type.
|
||||
*
|
||||
* Payload encodes which edge the user grabbed:
|
||||
* - `'start'`: arrow at the window edge closer to `wall.start`. The
|
||||
* opposite edge (toward `wall.end`) stays fixed.
|
||||
* - `'end'`: arrow at the edge closer to `wall.end`. The wall-start
|
||||
* edge stays fixed.
|
||||
*
|
||||
* Uses the scene-write preview pattern (writes directly to `useScene`
|
||||
* each tick): the registry layer's `effectiveNode` only merges live
|
||||
* overrides for walls, so an override-based preview wouldn't show on
|
||||
* windows. The dispatcher snapshots / pauses history at start, so
|
||||
* per-tick scene writes still collapse to one undoable entry on commit.
|
||||
*/
|
||||
export const windowWidthAffordance: FloorplanAffordance<WindowNode> = {
|
||||
start({ node, payload, nodes, initialPlanPoint }): FloorplanAffordanceSession {
|
||||
const { side } = payload as WindowWidthPayload
|
||||
const windowId = node.id as AnyNodeId
|
||||
const wall = node.wallId ? (nodes[node.wallId as AnyNodeId] as WallNode | undefined) : undefined
|
||||
|
||||
const initialWidth = node.width
|
||||
const initialWindowX = node.position[0]
|
||||
const initialWindowY = node.position[1]
|
||||
const initialWindowZ = node.position[2]
|
||||
|
||||
const growDir = side === 'end' ? 1 : -1
|
||||
const anchorX =
|
||||
side === 'end' ? initialWindowX - initialWidth / 2 : initialWindowX + initialWidth / 2
|
||||
|
||||
const wallStart: readonly [number, number] = wall ? wall.start : [0, 0]
|
||||
const wallEnd: readonly [number, number] = wall ? wall.end : [1, 0]
|
||||
const dx = wallEnd[0] - wallStart[0]
|
||||
const dz = wallEnd[1] - wallStart[1]
|
||||
const wallLength = Math.hypot(dx, dz) || 1
|
||||
const dirX = dx / wallLength
|
||||
const dirZ = dz / wallLength
|
||||
|
||||
const maxWidth = growDir > 0 ? wallLength - anchorX : anchorX
|
||||
|
||||
const projectToWallLocalX = (planPoint: readonly [number, number]) => {
|
||||
return (planPoint[0] - wallStart[0]) * dirX + (planPoint[1] - wallStart[1]) * dirZ
|
||||
}
|
||||
|
||||
const initialPointerLocalX = projectToWallLocalX(initialPlanPoint)
|
||||
|
||||
let lastWidth = initialWidth
|
||||
let lastWindowX = initialWindowX
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint }) {
|
||||
const currentLocalX = projectToWallLocalX(planPoint)
|
||||
const delta = (currentLocalX - initialPointerLocalX) * growDir
|
||||
const newWidth = Math.min(
|
||||
Math.max(MIN_WINDOW_WIDTH, initialWidth + delta),
|
||||
Math.max(MIN_WINDOW_WIDTH, maxWidth),
|
||||
)
|
||||
const newWindowX = anchorX + growDir * (newWidth / 2)
|
||||
lastWidth = newWidth
|
||||
lastWindowX = newWindowX
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: windowId,
|
||||
data: {
|
||||
width: newWidth,
|
||||
position: [newWindowX, initialWindowY, initialWindowZ],
|
||||
},
|
||||
},
|
||||
])
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: windowId,
|
||||
data: {
|
||||
width: lastWidth,
|
||||
position: [lastWindowX, initialWindowY, initialWindowZ],
|
||||
},
|
||||
},
|
||||
])
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -32,6 +32,16 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
// the window had when the move started.
|
||||
const startLocalY = node.position[1]
|
||||
|
||||
// Track the last successful placement so `commit()` can write it
|
||||
// atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`.
|
||||
let lastValid: {
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
side: WindowNode['side']
|
||||
parentId: string
|
||||
wallId: string
|
||||
} | null = null
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
@@ -48,16 +58,18 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
node.height,
|
||||
)
|
||||
|
||||
lastValid = {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: hit.wall.id,
|
||||
wallId: hit.wall.id,
|
||||
}
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: hit.wall.id,
|
||||
wallId: hit.wall.id,
|
||||
},
|
||||
data: lastValid,
|
||||
},
|
||||
])
|
||||
},
|
||||
@@ -74,6 +86,22 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
)
|
||||
return !overlapping
|
||||
},
|
||||
commit() {
|
||||
// Own the atomic write so the overlay takes the deterministic
|
||||
// commit-path (revert → resume → session.commit()). The dispatcher's
|
||||
// diff path would otherwise re-derive the final state by comparing
|
||||
// the post-apply scene to the snapshot — that works most of the
|
||||
// time but produces an empty diff (and silent revert) when the
|
||||
// committed move lands on the same `parentId` with identical data.
|
||||
// See `doorFloorplanMoveTarget.commit` for the original fix.
|
||||
if (!lastValid) return
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: lastValid,
|
||||
},
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
return session
|
||||
|
||||
@@ -135,6 +135,30 @@ export function buildWindowFloorplan(
|
||||
kind: 'move-handle',
|
||||
point: [cx, cz],
|
||||
})
|
||||
|
||||
// Width-resize arrows at each side of the window (along the wall
|
||||
// direction). Pointer-down on either routes through the window's
|
||||
// `resize-width` affordance — anchored at the opposite edge, clamped
|
||||
// to wall bounds. Mirrors the 3D `WindowSideArrow` width drag and the
|
||||
// door's 2D pattern.
|
||||
const startEdgeX = cx - dirX * halfWidth
|
||||
const startEdgeZ = cz - dirZ * halfWidth
|
||||
const endEdgeX = cx + dirX * halfWidth
|
||||
const endEdgeZ = cz + dirZ * halfWidth
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [startEdgeX, startEdgeZ],
|
||||
angle: Math.atan2(-dirZ, -dirX),
|
||||
affordance: 'resize-width',
|
||||
payload: { side: 'start' },
|
||||
})
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [endEdgeX, endEdgeZ],
|
||||
angle: Math.atan2(dirZ, dirX),
|
||||
affordance: 'resize-width',
|
||||
payload: { side: 'end' },
|
||||
})
|
||||
}
|
||||
|
||||
// Placement-measurement dimensions when actively moving — same
|
||||
|
||||
@@ -495,13 +495,13 @@ export default function WindowPanel() {
|
||||
|
||||
{showWindowTypeSection && (
|
||||
<PanelSection title="Window Type">
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2 px-1 pt-1">
|
||||
{windowTypeOptions.map((option) => {
|
||||
const isSelected = displayedWindowType === option.value
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
|
||||
'flex min-h-12 items-center rounded-lg border px-3 py-2.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||
|
||||
@@ -162,8 +162,22 @@ const WindowTool: React.FC = () => {
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
if (draftRef.current) {
|
||||
if (event.node.id !== draftRef.current.parentId) {
|
||||
// Wall changed without enter/leave: must updateNode to reparent
|
||||
// Update the scene store on every move so the 2D floor plan
|
||||
// stays in sync (it re-renders from `node.position`). Only
|
||||
// forward `parentId` / `wallId` when the wall actually changed
|
||||
// — otherwise the reparent path churns the host wall's
|
||||
// `children` array every tick, which re-renders the wall and
|
||||
// briefly draws its 0-vertex placeholder geometry (WebGPU then
|
||||
// flags "Vertex buffer slot 0 ... was not set").
|
||||
const isSameWall = event.node.id === draftRef.current.parentId
|
||||
if (isSameWall) {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
})
|
||||
markWallDirty(event.node.id)
|
||||
} else {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
@@ -171,15 +185,6 @@ const WindowTool: React.FC = () => {
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
} else {
|
||||
// Same wall: update Three.js mesh directly to avoid store churn
|
||||
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
|
||||
if (draftMesh) {
|
||||
draftMesh.position.set(clampedX, clampedY, 0)
|
||||
draftMesh.rotation.set(0, itemRotation, 0)
|
||||
draftMesh.updateMatrixWorld(true)
|
||||
}
|
||||
markWallDirty(event.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user