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
@@ -52,10 +52,12 @@ git push -u origin HEAD
|
||||
Check for an existing PR first:
|
||||
|
||||
```bash
|
||||
gh pr view --json url 2>/dev/null
|
||||
gh pr view --json number,url,title,body 2>/dev/null
|
||||
```
|
||||
|
||||
If none exists, create it. Pass the body via HEREDOC to preserve markdown formatting:
|
||||
### 3a. No existing PR → create one
|
||||
|
||||
Pass the body via HEREDOC to preserve markdown formatting:
|
||||
|
||||
```bash
|
||||
gh pr create --title "short, scope-prefixed title" --body "$(cat <<'EOF'
|
||||
@@ -85,7 +87,40 @@ EOF
|
||||
|
||||
Keep the title under ~70 characters. Use a scope prefix when there's an obvious one (`viewer:`, `core:`, `editor:`, `mcp:`).
|
||||
|
||||
If a PR already exists, print its URL and stop — don't recreate.
|
||||
### 3b. PR already exists → update its description
|
||||
|
||||
Do **not** recreate the PR. Refresh the existing body so it reflects everything currently on the branch, while keeping the template structure and any reviewer-meaningful state the user already set.
|
||||
|
||||
1. Capture the existing body and the full branch history:
|
||||
|
||||
```bash
|
||||
gh pr view --json number,body -q '.number, .body' > /tmp/existing-pr.txt
|
||||
git log --oneline main..HEAD
|
||||
git diff --stat main..HEAD
|
||||
```
|
||||
|
||||
2. Reconstruct the body section-by-section. Keep the four template headings in the same order (`## What does this PR do?`, `## How to test`, `## Screenshots / screen recording`, `## Checklist`). For each section:
|
||||
|
||||
- **What does this PR do?** — rewrite from the *current* commits and diff on the branch, not from memory. Preserve any `Fixes #123` / `Refs #123` lines from the old body.
|
||||
- **How to test** — regenerate concrete steps for the *current* behaviour. If a previous step is still valid, keep its wording; drop steps that no longer apply; add steps for new commits.
|
||||
- **Screenshots / screen recording** — preserve the existing content verbatim (links, embedded images, "N/A — …"). Do not blank it out. Only change it if the user explicitly provided a new recording.
|
||||
- **Checklist** — preserve the user's tick state (`[x]` vs `[ ]`) for every item that still exists in the template. Add any new template items as unchecked.
|
||||
|
||||
If the old body contains extra sections the template doesn't have (e.g. a manual "Notes" block), keep them at the end.
|
||||
|
||||
3. Apply the update:
|
||||
|
||||
```bash
|
||||
gh pr edit <number> --body "$(cat <<'EOF'
|
||||
## What does this PR do?
|
||||
…
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Use `gh pr edit --title` *only* if the branch's scope has clearly changed; otherwise leave the title alone.
|
||||
|
||||
4. Print the PR URL so the user can confirm the edit.
|
||||
|
||||
## 4. Report
|
||||
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
// `loaded` guard inside `../lib/bootstrap` keeps the side effect
|
||||
// idempotent under HMR.
|
||||
import '../lib/bootstrap'
|
||||
import type { ReactNode } from 'react'
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
|
||||
export function ClientBootstrap({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== 'development') return
|
||||
// Loaded here (not via a `<Script>` tag in <head>) to avoid React's
|
||||
// "script inside a React component" hydration warning. The package
|
||||
// is already a direct dep, so we don't need the CDN auto-global.
|
||||
import('react-scan').then(({ scan }) => scan({ enabled: true }))
|
||||
}, [])
|
||||
return children
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import './.next/types/routes.d.ts'
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -28,6 +28,40 @@ export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): s
|
||||
return 'default' // fallback for orphaned items
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the building id that contains the given level, or `null` if
|
||||
* the level is unparented or no enclosing building exists.
|
||||
*
|
||||
* Most scenes record the relationship via `level.parentId →
|
||||
* building.id`, but older serialisations occasionally drop `parentId`
|
||||
* even though the building's `children` array still references the
|
||||
* level. The fallback scan covers that case.
|
||||
*
|
||||
* Used by `FloorplanRegistryLayer` to discover building-scoped kinds
|
||||
* (`def.floorplanScope === 'building'`) without hardcoding any kind
|
||||
* name in the editor layer.
|
||||
*/
|
||||
export function resolveBuildingForLevel(
|
||||
levelId: AnyNodeId,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): AnyNodeId | null {
|
||||
const level = nodes[levelId] as AnyNode | undefined
|
||||
if (!level) return null
|
||||
const directParent = (level as { parentId?: AnyNodeId | null }).parentId ?? null
|
||||
if (directParent) {
|
||||
const candidate = nodes[directParent]
|
||||
if (candidate?.type === 'building') return candidate.id as AnyNodeId
|
||||
}
|
||||
for (const candidate of Object.values(nodes)) {
|
||||
if (candidate?.type !== 'building') continue
|
||||
const children = (candidate as { children?: AnyNodeId[] }).children
|
||||
if (Array.isArray(children) && children.includes(levelId)) {
|
||||
return candidate.id as AnyNodeId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Call this once at app initialization. Returns an unsubscribe function that
|
||||
// detaches the scene-store listener (useful when the editor is unmounted so
|
||||
// the spatial grid singleton does not hold stale references to old scenes).
|
||||
|
||||
@@ -40,6 +40,7 @@ export {
|
||||
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
||||
export {
|
||||
initSpatialGridSync,
|
||||
resolveBuildingForLevel,
|
||||
resolveLevelId,
|
||||
} from './hooks/spatial-grid/spatial-grid-sync'
|
||||
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
||||
@@ -53,11 +54,13 @@ export {
|
||||
} from './lib/door-operation'
|
||||
export { getRenderableSlabPolygon } from './lib/slab-polygon'
|
||||
export {
|
||||
type AutoCeilingSyncPlan,
|
||||
type AutoSlabSyncPlan,
|
||||
detectSpacesForLevel,
|
||||
initSpaceDetectionSync,
|
||||
isSpaceDetectionPaused,
|
||||
pauseSpaceDetection,
|
||||
planAutoCeilingsForLevel,
|
||||
planAutoSlabsForLevel,
|
||||
resumeSpaceDetection,
|
||||
type Space,
|
||||
@@ -100,6 +103,7 @@ export {
|
||||
export {
|
||||
default as useLiveNodeOverrides,
|
||||
type LiveNodeOverrides,
|
||||
getEffectiveNode,
|
||||
} from './store/use-live-node-overrides'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
@@ -172,11 +176,14 @@ export {
|
||||
} from './systems/wall/wall-mitering'
|
||||
export {
|
||||
constrainWallMoveDeltaToAxis,
|
||||
getLinkedWallUpdates,
|
||||
getPerpendicularWallMoveAxis,
|
||||
getPlannedLinkedWallUpdates,
|
||||
planWallMoveJunctions,
|
||||
type WallMoveAxis,
|
||||
type WallMoveBridgePlan,
|
||||
type WallMoveJunctionPlan,
|
||||
type WallMoveLinkedWallTargetPlan,
|
||||
type WallPlanPoint,
|
||||
} from './systems/wall/wall-move'
|
||||
export type { SceneGraph } from './utils/clone-scene-graph'
|
||||
|
||||
@@ -47,6 +47,12 @@ export type AutoSlabSyncPlan = {
|
||||
delete: Array<SlabNodeType['id']>
|
||||
}
|
||||
|
||||
export type AutoCeilingSyncPlan = {
|
||||
create: CeilingNodeType[]
|
||||
update: Array<{ id: CeilingNodeType['id']; data: Partial<CeilingNodeType> }>
|
||||
delete: Array<CeilingNodeType['id']>
|
||||
}
|
||||
|
||||
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
|
||||
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
|
||||
const ROOM_CURVE_TOLERANCE = 0.04
|
||||
@@ -650,12 +656,10 @@ function syncAutoSlabsForLevel(
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoCeilingsForLevel(
|
||||
levelId: string,
|
||||
export function planAutoCeilingsForLevel(
|
||||
roomPolygons: Point2D[][],
|
||||
existingCeilings: CeilingNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
): AutoCeilingSyncPlan {
|
||||
const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls)
|
||||
const manualSignatures = new Set(
|
||||
manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))),
|
||||
@@ -782,16 +786,31 @@ function syncAutoCeilingsForLevel(
|
||||
)
|
||||
}
|
||||
|
||||
if (ceilingsToDelete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(ceilingsToDelete)
|
||||
return {
|
||||
create: ceilingsToCreate,
|
||||
update: ceilingsToUpdate,
|
||||
delete: ceilingsToDelete,
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoCeilingsForLevel(
|
||||
levelId: string,
|
||||
roomPolygons: Point2D[][],
|
||||
existingCeilings: CeilingNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings)
|
||||
|
||||
if (plan.delete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(plan.delete)
|
||||
}
|
||||
|
||||
if (ceilingsToUpdate.length > 0) {
|
||||
sceneStore.getState().updateNodes(ceilingsToUpdate)
|
||||
if (plan.update.length > 0) {
|
||||
sceneStore.getState().updateNodes(plan.update)
|
||||
}
|
||||
|
||||
if (ceilingsToCreate.length > 0) {
|
||||
sceneStore.getState().createNodes(ceilingsToCreate.map((node) => ({ node, parentId: levelId })))
|
||||
if (plan.create.length > 0) {
|
||||
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ function buildFixture() {
|
||||
function makeScene(nodes: Record<string, AnyNode>): SceneApi {
|
||||
return {
|
||||
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
||||
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
update: () => {},
|
||||
upsert: () => ID(''),
|
||||
delete: () => {},
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// In-world resize / move arrow descriptors. Each `NodeDefinition` may
|
||||
// declare a `handles` list (or a `(node) => list` function for shape-
|
||||
// dependent affordances). The editor mounts a single generic component
|
||||
// that reads these descriptors and renders the arrows / drag logic — no
|
||||
// per-kind handles file needed.
|
||||
//
|
||||
// Pure data + small per-descriptor callbacks: no Three.js, React, or
|
||||
// editor imports here so this stays in core. The descriptors are
|
||||
// evaluated by the editor at drag time (`apply` etc.) so the callbacks
|
||||
// run in the editor's context — they see the live node and the scene
|
||||
// API but otherwise do not import 3D libraries.
|
||||
//
|
||||
// Layered intentionally:
|
||||
// - axis-resize : symmetric scaling around center (column W/D, height)
|
||||
// - edge-resize : anchored on one edge, the other follows the pointer
|
||||
// (door width: drag right edge, left edge stays)
|
||||
// - vertical-resize: linear-resize specialised for world-Y (height arrow
|
||||
// anchored at bottom; window top-edge anchored at
|
||||
// bottom; window bottom-edge anchored at top)
|
||||
// - radial-resize : 1:1 outward growth of a radial field (column radius)
|
||||
// - arc-resize : curved/spiral stair sweep / inner-radius / rise
|
||||
// - endpoint-move : wall / fence endpoint drag (snapping is bespoke,
|
||||
// so it delegates to a kind-supplied callback)
|
||||
|
||||
import type { AnyNode } from '../schema/types'
|
||||
import type { SceneApi } from './types'
|
||||
|
||||
/**
|
||||
* Editor-facing verbs that handle descriptors can invoke.
|
||||
*
|
||||
* Parallel to {@link SceneApi} but exposes EDITOR state mutations (move
|
||||
* tools, endpoint dragging, etc.) instead of scene-data writes. Descriptors
|
||||
* receive a concrete implementation from the editor at drag time — `core`
|
||||
* only carries the interface so node definitions can call into editor
|
||||
* affordances without importing the editor package.
|
||||
*
|
||||
* Minimal verb set today; grow it as new descriptor variants land
|
||||
* (engageCurve for wall/fence curving, etc.).
|
||||
*/
|
||||
export type EditorApi = {
|
||||
/**
|
||||
* Hand the node to its registered move tool (the same path the floating
|
||||
* menu's Move icon uses). Implementations clear any in-progress endpoint
|
||||
* or curving state so the move starts from a clean slate.
|
||||
*/
|
||||
engageMove: (node: AnyNode) => void
|
||||
/**
|
||||
* Engage endpoint drag for kinds that own start / end anchors (walls,
|
||||
* fences). No-ops for kinds without endpoints.
|
||||
*/
|
||||
engageEndpointMove: (node: AnyNode, endpoint: 'start' | 'end') => void
|
||||
}
|
||||
|
||||
export type HandlePortal = 'self' | 'parent' | 'grandparent'
|
||||
|
||||
export type HandleAxis = 'x' | 'y' | 'z'
|
||||
|
||||
export type HandleAnchor = 'center' | 'min' | 'max'
|
||||
|
||||
/** 3D position + rotation of the arrow in its portal target's local space. */
|
||||
export type HandlePlacement<N> = {
|
||||
/**
|
||||
* `sceneApi` is supplied so descriptors that depend on cross-node state
|
||||
* (elevator height resolving level entries, future cross-kind handles)
|
||||
* can compute placement against the live scene. Existing descriptors
|
||||
* that only need `node` can ignore the second argument.
|
||||
*/
|
||||
position: (node: N, sceneApi: SceneApi) => readonly [number, number, number]
|
||||
/** Optional Y rotation (radians). Defaults to 0. */
|
||||
rotationY?: (node: N, sceneApi: SceneApi) => number
|
||||
}
|
||||
|
||||
export type Cursor = 'ew-resize' | 'ns-resize' | 'move' | 'grab' | 'grabbing'
|
||||
|
||||
/**
|
||||
* Visual decoration shown alongside a handle while the user is hovering
|
||||
* or dragging it. Today: a thin horizontal ring at a node-local radius —
|
||||
* the curved-stair width / inner-radius arrows use this to trace the
|
||||
* outer rim / inner pillar so the user sees what the drag affects.
|
||||
*
|
||||
* Pure data: the editor's arrow renderer reads it and mounts the visual.
|
||||
*/
|
||||
export type HandleDecoration<N> = {
|
||||
kind: 'ring'
|
||||
/** Node-local radius of the ring (XZ plane). */
|
||||
radius: (node: N) => number
|
||||
/** Node-local Y of the ring. Defaults to 0. */
|
||||
y?: (node: N) => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear resize along a single local axis. Covers width / depth / height
|
||||
* arrows whose visible behaviour is "drag the +axis edge, the dimension
|
||||
* grows."
|
||||
*
|
||||
* `anchor` controls which side stays fixed:
|
||||
* - 'center' : symmetric — both edges move ±delta (column width/depth).
|
||||
* - 'min' : the -axis edge is fixed; drag the +axis edge by `delta`
|
||||
* grows the value by `delta` (column height with origin at
|
||||
* base; door height with bottom anchored).
|
||||
* - 'max' : the +axis edge is fixed; drag the -axis edge.
|
||||
*
|
||||
* `apply(node, newValue)` returns the partial patch. Use it to write
|
||||
* sibling fields too (e.g. door 'max' anchor re-centers `position[0]`).
|
||||
*/
|
||||
export type LinearResizeHandle<N> = {
|
||||
kind: 'linear-resize'
|
||||
/** Local axis. The arrow's chevron points along +axis. */
|
||||
axis: HandleAxis
|
||||
anchor: HandleAnchor
|
||||
currentValue: (node: N) => number
|
||||
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
|
||||
min?: number | ((node: N, sceneApi: SceneApi) => number)
|
||||
max?: number | ((node: N, sceneApi: SceneApi) => number)
|
||||
placement: HandlePlacement<N>
|
||||
/**
|
||||
* Defaults to 'self' (arrow lives in the selected node's own mesh).
|
||||
* 'parent' uses the parent mesh — used by doors/windows whose handles
|
||||
* need to ride the wall's rotation.
|
||||
*/
|
||||
portal?: HandlePortal
|
||||
cursor?: Cursor
|
||||
/** Optional visual guide shown while the arrow is hovered or dragging. */
|
||||
decoration?: HandleDecoration<N>
|
||||
}
|
||||
|
||||
/**
|
||||
* 1:1 outward growth — dragging the arrow outward by `delta` grows the
|
||||
* value by `delta` (the visible edge follows the pointer). Use for radii
|
||||
* and other fields where the conceptual model is "the +axis edge IS the
|
||||
* thing being moved" rather than "the size IS being scaled."
|
||||
*/
|
||||
export type RadialResizeHandle<N> = {
|
||||
kind: 'radial-resize'
|
||||
axis: HandleAxis
|
||||
currentValue: (node: N) => number
|
||||
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
|
||||
min?: number | ((node: N, sceneApi: SceneApi) => number)
|
||||
max?: number | ((node: N, sceneApi: SceneApi) => number)
|
||||
placement: HandlePlacement<N>
|
||||
portal?: HandlePortal
|
||||
/** Optional visual guide shown while the arrow is hovered or dragging. */
|
||||
decoration?: HandleDecoration<N>
|
||||
}
|
||||
|
||||
/**
|
||||
* Curved / spiral stair sweep arrows. The renderer raycasts a horizontal
|
||||
* plane through the arrow's Y and emits the angular delta (radians,
|
||||
* signed, normalised to [-π, π]) around the node's local origin.
|
||||
*
|
||||
* Unlike the linear variants, `apply` receives the raw cursor delta
|
||||
* (not a `newValue`) because sweep handles typically write multiple
|
||||
* fields off the delta (`sweepAngle` AND `rotation` — re-orienting the
|
||||
* arc so the opposite edge stays world-fixed). Descriptor-internal
|
||||
* math handles the per-end sign and any clamping; the renderer stays
|
||||
* out of it.
|
||||
*/
|
||||
export type ArcResizeHandle<N = any> = {
|
||||
kind: 'arc-resize'
|
||||
/**
|
||||
* Marks the drag mode. Only 'angular' uses the polar plane renderer;
|
||||
* 'radial' and 'vertical' degenerate to `linear-resize` (axis 'x' /
|
||||
* 'y') so descriptors should prefer that for those cases.
|
||||
*/
|
||||
axis: 'angular'
|
||||
/** Optional metadata for descriptors that bundle two handles per kind. */
|
||||
end?: 'start' | 'end'
|
||||
apply: (initialNode: N, delta: number, sceneApi: SceneApi) => Partial<N>
|
||||
placement: HandlePlacement<N>
|
||||
portal?: HandlePortal
|
||||
/** Optional visual guide shown while the arrow is hovered or dragging. */
|
||||
decoration?: HandleDecoration<N>
|
||||
/**
|
||||
* Visual override. Defaults to the standard chevron (used by the
|
||||
* stair-sweep extend handles). 'rotate' renders a two-headed curved
|
||||
* arrow icon, intended for whole-node rotation handles.
|
||||
*/
|
||||
shape?: 'chevron' | 'rotate'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall / fence endpoint drag. Snapping and adjacency belong to the kind,
|
||||
* so the descriptor declares the placement and hands the world-space
|
||||
* pointer position back to `apply`. The kind can splice walls, snap to
|
||||
* a grid, merge with a neighbour, etc., and returns the partial patch.
|
||||
*/
|
||||
export type EndpointMoveHandle<N> = {
|
||||
kind: 'endpoint-move'
|
||||
endpoint: 'start' | 'end'
|
||||
placement: HandlePlacement<N>
|
||||
/** Called with the world-space hit on the ground plane. */
|
||||
apply: (
|
||||
node: N,
|
||||
worldPoint: readonly [number, number, number],
|
||||
sceneApi: SceneApi,
|
||||
) => Partial<N>
|
||||
portal?: HandlePortal
|
||||
}
|
||||
|
||||
// Default to `any` so type-erased renderers can hold `HandleDescriptor[]`
|
||||
// without each variant's contravariant `currentValue: (node: N) => ...`
|
||||
// callback fighting the union widening. Per-kind defs supply a real N.
|
||||
/**
|
||||
* Click-to-engage affordance. The descriptor doesn't drive a drag — its
|
||||
* single job is to mount a click target at `placement` and dispatch a
|
||||
* verb on the editor API when the user clicks. Used by wall side-move
|
||||
* (engage move tool) and wall corner pickers (engage endpoint move).
|
||||
*
|
||||
* The renderer picks the visual from `shape`. Default `'arrow'` reuses
|
||||
* the chevron shape every resize handle uses. `'corner-picker'` renders
|
||||
* a dashed vertical leader + billboarded hex disc + ring, anchored at
|
||||
* `placement.position` and extending up to `nodeHeight(node)`.
|
||||
*/
|
||||
export type TapActionHandle<N = any> = {
|
||||
kind: 'tap-action'
|
||||
placement: HandlePlacement<N>
|
||||
/**
|
||||
* Dispatched on pointer-down. Use scene/editor APIs to read state +
|
||||
* trigger the desired action.
|
||||
*/
|
||||
onActivate: (node: N, scene: SceneApi, editor: EditorApi) => void
|
||||
/** Visual override; defaults to the standard chevron arrow. */
|
||||
shape?: 'arrow' | 'corner-picker'
|
||||
/**
|
||||
* Required when `shape: 'corner-picker'` — controls the dashed leader's
|
||||
* vertical extent. Pure callback so the descriptor doesn't need to
|
||||
* import 3D libs.
|
||||
*/
|
||||
nodeHeight?: (node: N) => number
|
||||
portal?: HandlePortal
|
||||
cursor?: Cursor
|
||||
}
|
||||
|
||||
export type HandleDescriptor<N = any> =
|
||||
| LinearResizeHandle<N>
|
||||
| RadialResizeHandle<N>
|
||||
| ArcResizeHandle<N>
|
||||
| EndpointMoveHandle<N>
|
||||
| TapActionHandle<N>
|
||||
|
||||
/**
|
||||
* Static array, or a function for shape-dependent cases (column
|
||||
* crossSection / supportStyle, stair-segment segmentType, etc.).
|
||||
*/
|
||||
export type HandleList<N> = HandleDescriptor<N>[] | ((node: N) => HandleDescriptor<N>[])
|
||||
@@ -1,7 +1,9 @@
|
||||
export {
|
||||
discoverPlugins,
|
||||
getSelectableKinds,
|
||||
isRegistryMovable,
|
||||
isRegistrySelectable,
|
||||
kindsWithFloorplanScope,
|
||||
loadPlugin,
|
||||
nodeRegistry,
|
||||
type PluginDiscovery,
|
||||
@@ -15,6 +17,21 @@ export {
|
||||
collectDescendants,
|
||||
type SpatialQuery,
|
||||
} from './relations-resolver'
|
||||
export type {
|
||||
ArcResizeHandle,
|
||||
Cursor,
|
||||
EditorApi,
|
||||
EndpointMoveHandle,
|
||||
HandleAnchor,
|
||||
HandleAxis,
|
||||
HandleDescriptor,
|
||||
HandleList,
|
||||
HandlePlacement,
|
||||
HandlePortal,
|
||||
LinearResizeHandle,
|
||||
RadialResizeHandle,
|
||||
TapActionHandle,
|
||||
} from './handles'
|
||||
export { createSceneApi, type SceneStoreLike } from './scene-api'
|
||||
export type {
|
||||
Affordance,
|
||||
|
||||
@@ -3,6 +3,23 @@ import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types'
|
||||
|
||||
const HOST_API_VERSION = 1 as const
|
||||
|
||||
// True in dev / test builds, false in production. Tries Vite's
|
||||
// `import.meta.env.DEV` first (the editor app's bundler) and falls back
|
||||
// to `process.env.NODE_ENV !== 'production'` for Node test runners.
|
||||
function isDevMode(): boolean {
|
||||
try {
|
||||
const meta = import.meta as { env?: { DEV?: boolean } }
|
||||
if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV
|
||||
} catch {
|
||||
// import.meta unavailable in some CJS contexts — fall through.
|
||||
}
|
||||
if (typeof process !== 'undefined' && process.env?.NODE_ENV) {
|
||||
return process.env.NODE_ENV !== 'production'
|
||||
}
|
||||
// No environment signal — be safe and treat as production.
|
||||
return false
|
||||
}
|
||||
|
||||
class NodeRegistryImpl implements NodeRegistry {
|
||||
private readonly defs = new Map<string, AnyNodeDefinition>()
|
||||
|
||||
@@ -28,9 +45,6 @@ class NodeRegistryImpl implements NodeRegistry {
|
||||
|
||||
// Internal — exposed via registerNode below.
|
||||
_register(def: AnyNodeDefinition): void {
|
||||
if (this.defs.has(def.kind)) {
|
||||
throw new Error(`[registry] duplicate node kind: "${def.kind}" already registered`)
|
||||
}
|
||||
if (typeof def.kind !== 'string' || def.kind.length === 0) {
|
||||
throw new Error('[registry] NodeDefinition.kind must be a non-empty string')
|
||||
}
|
||||
@@ -39,6 +53,21 @@ class NodeRegistryImpl implements NodeRegistry {
|
||||
`[registry] NodeDefinition.schemaVersion must be a positive integer (kind: "${def.kind}")`,
|
||||
)
|
||||
}
|
||||
// Duplicate-kind handling depends on environment:
|
||||
// - **Production**: throw. The plugin-authoring contract
|
||||
// (`wiki/architecture/plugin-authoring.md`) guarantees that two
|
||||
// plugins shipping `kind: 'couch'` is a startup-time error, not
|
||||
// a silent overwrite — collisions need to be visible.
|
||||
// - **Dev (HMR)**: replace with a warning. Saving `def.ts` would
|
||||
// otherwise either crash on re-execute or skip it entirely,
|
||||
// leaving stale descriptors pinned in memory.
|
||||
if (this.defs.has(def.kind)) {
|
||||
if (isDevMode()) {
|
||||
console.warn(`[registry] re-registering node kind "${def.kind}" (HMR)`)
|
||||
} else {
|
||||
throw new Error(`[registry] duplicate node kind: "${def.kind}" already registered`)
|
||||
}
|
||||
}
|
||||
this.defs.set(def.kind, def)
|
||||
}
|
||||
|
||||
@@ -85,6 +114,38 @@ export function isRegistrySelectable(kind: string): boolean {
|
||||
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Kinds whose `def.floorplanScope` matches the requested scope. Used by
|
||||
* `FloorplanRegistryLayer` to discover building-scoped kinds (e.g.
|
||||
* elevator) without hardcoding kind names in the editor layer. `'level'`
|
||||
* is the default, so `kindsWithFloorplanScope('level')` includes kinds
|
||||
* that didn't set the field at all.
|
||||
*/
|
||||
export function kindsWithFloorplanScope(scope: 'level' | 'building'): string[] {
|
||||
const result: string[] = []
|
||||
for (const [kind, def] of nodeRegistry.entries()) {
|
||||
const declared = def.floorplanScope ?? 'level'
|
||||
if (declared === scope) result.push(kind)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the kind is movable from a 2D floor-plan handle —
|
||||
* either via `capabilities.movable`, an explicit
|
||||
* `def.floorplanMoveTarget`, or an `affordanceTools.move` 3D mover that
|
||||
* the floating action menu can engage. Replaces the kind-name ternary
|
||||
* chain in `floating-action-menu.tsx`.
|
||||
*/
|
||||
export function isRegistryMovable(kind: string): boolean {
|
||||
const def = nodeRegistry.get(kind)
|
||||
if (!def) return false
|
||||
if (def.capabilities.movable !== undefined) return true
|
||||
if (def.floorplanMoveTarget !== undefined) return true
|
||||
if (def.affordanceTools?.move !== undefined) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export async function loadPlugin(plugin: Plugin): Promise<void> {
|
||||
if (plugin.apiVersion !== HOST_API_VERSION) {
|
||||
throw new Error(
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeNode(kind: string, idStr: string, extra: Partial<AnyNode> = {}): An
|
||||
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
|
||||
return {
|
||||
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
||||
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
update: () => {},
|
||||
upsert: () => id(''),
|
||||
delete: () => {},
|
||||
|
||||
@@ -50,6 +50,10 @@ export function createSceneApi(store: SceneStoreLike): SceneApi {
|
||||
return store.getState().nodes[id] as N | undefined
|
||||
},
|
||||
|
||||
nodes() {
|
||||
return store.getState().nodes
|
||||
},
|
||||
|
||||
update(id, patch) {
|
||||
captureIfNeeded(id)
|
||||
store.getState().updateNode(id, patch)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BufferGeometry, Object3D } from 'three'
|
||||
import type { ZodObject, z } from 'zod'
|
||||
import type { MaterialSchema } from '../schema/material'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import type { HandleList } from './handles'
|
||||
|
||||
// ─── GeometryContext ─────────────────────────────────────────────────
|
||||
//
|
||||
@@ -146,6 +147,24 @@ export type FloorplanStyle = {
|
||||
strokeLinejoin?: 'miter' | 'round' | 'bevel'
|
||||
strokeOpacity?: number
|
||||
fillOpacity?: number
|
||||
/**
|
||||
* SVG `pointer-events`. Default (undefined) lets the renderer pick its
|
||||
* normal behaviour — `visiblePainted` for filled shapes, `stroke` for
|
||||
* line / hit-line. Set `'none'` to make a primitive completely
|
||||
* passthrough — useful for chrome that should be visible but never
|
||||
* trigger selection or drag (e.g. a wall's body once it's already
|
||||
* selected, where only the side-arrows / corner handles should grab
|
||||
* the pointer).
|
||||
*/
|
||||
pointerEvents?: 'none' | 'auto' | 'all' | 'stroke' | 'fill' | 'visible' | 'visiblePainted'
|
||||
/**
|
||||
* CSS `cursor` for the rendered primitive. Defaults to inheriting the
|
||||
* registry entry wrapper's `cursor: 'pointer'`. Override to neutralise
|
||||
* a hover affordance — e.g. a selected wall body that catches the
|
||||
* pointer (to block fall-through to the slab below) but should not
|
||||
* advertise itself as a drag target.
|
||||
*/
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
// ─── ToolHint ────────────────────────────────────────────────────────
|
||||
@@ -272,6 +291,12 @@ export type FloorplanGeometry =
|
||||
/** Stroke width in screen pixels — converted to plan units by the dispatcher. */
|
||||
strokeWidthPx: number
|
||||
cursor?: string
|
||||
/**
|
||||
* Override the default `pointer-events="stroke"`. Use `'none'` when
|
||||
* a kind wants to keep the line painted (for hit-debugging or layout
|
||||
* stability) but route grabs through other affordances instead.
|
||||
*/
|
||||
pointerEvents?: 'none' | 'stroke' | 'auto'
|
||||
}
|
||||
/**
|
||||
* Endpoint manipulation handle — the 5-circle stack from the legacy
|
||||
@@ -341,6 +366,50 @@ export type FloorplanGeometry =
|
||||
kind: 'move-handle'
|
||||
point: FloorplanPoint
|
||||
}
|
||||
/**
|
||||
* Directional move handle drawn as an arrow pointing AWAY from the
|
||||
* owning node, rotated by `angle` (radians; 0 = +x). Used by wall to
|
||||
* place two arrows on perpendicular sides at the wall midpoint —
|
||||
* mirrors the 3D `WallMoveSideHandles`. Routes through the same
|
||||
* `onMoveHandlePointerDown` → `setMovingNode` path as `move-handle`.
|
||||
*/
|
||||
| {
|
||||
kind: 'move-arrow'
|
||||
point: FloorplanPoint
|
||||
/** Rotation in radians; 0 points along +x in plan coords. */
|
||||
angle: number
|
||||
/**
|
||||
* Optional affordance routing. When set, pointer-down on the arrow
|
||||
* starts a `def.floorplanAffordances?.[affordance]` session with the
|
||||
* given `payload` (same dispatch path as `edge-handle`) instead of
|
||||
* the default `setMovingNode` flow. Used by doors for the in-plane
|
||||
* width-resize handles that visually mirror the move arrow shape but
|
||||
* drive a different mutation.
|
||||
*/
|
||||
affordance?: string
|
||||
payload?: unknown
|
||||
}
|
||||
/**
|
||||
* Curved two-headed rotation arrow — the 2D counterpart of the 3D
|
||||
* `arc-resize` handle's `shape: 'rotate'` gizmo. Visually a short arc
|
||||
* with arrowheads at each end pointing tangentially in opposite
|
||||
* directions, so it reads as "rotate either way" rather than "drag
|
||||
* along a line." Always routes through an affordance (rotation has no
|
||||
* sensible default Move semantics).
|
||||
*
|
||||
* `angle` is the radial-outward direction in plan coords — the icon's
|
||||
* local +X axis points away from the pivot, with the arc curving
|
||||
* around it. Emitters typically compute this as
|
||||
* `atan2(handle.y − pivot.y, handle.x − pivot.x)`.
|
||||
*/
|
||||
| {
|
||||
kind: 'rotate-arrow'
|
||||
point: FloorplanPoint
|
||||
/** Radial-outward direction from the rotation pivot, in radians. */
|
||||
angle: number
|
||||
affordance: string
|
||||
payload?: unknown
|
||||
}
|
||||
/**
|
||||
* Centered length / distance label. Renders as a small rounded
|
||||
* background plate with text, oriented along `angle` (radians). The
|
||||
@@ -418,20 +487,40 @@ export type FloorplanAffordanceSession = {
|
||||
/** Node IDs the drag may mutate. Used by the dispatcher for the snapshot. */
|
||||
affectedIds: AnyNodeId[]
|
||||
/**
|
||||
* Run a single drag tick. Implementations call `scene.updateNodes` to
|
||||
* preview the next position. Snap logic, linked-node cascade, and
|
||||
* angle locking live here.
|
||||
* Run a single drag tick. Two patterns are supported:
|
||||
* - **Scene-write preview**: implementation calls `scene.updateNodes`
|
||||
* each tick; the dispatcher captures a pre-drag snapshot and runs
|
||||
* a single-undo dance on commit (revert → resume → re-apply diff).
|
||||
* Suitable for affordances whose commit is a pure diff of the
|
||||
* affected fields.
|
||||
* - **Live-override preview**: implementation publishes per-frame
|
||||
* overrides to `useLiveNodeOverrides` (or another preview store);
|
||||
* `useScene` stays untouched during the drag. The session must
|
||||
* also expose `commit()` below, since there's no scene diff for
|
||||
* the dispatcher to write back.
|
||||
*
|
||||
* Snap logic, linked-node cascade, and angle locking live here.
|
||||
*/
|
||||
apply(args: {
|
||||
planPoint: FloorplanAffordancePoint
|
||||
modifiers: FloorplanAffordanceModifiers
|
||||
}): void
|
||||
/**
|
||||
* Called on pointer-up. Return `true` if the scene's current state
|
||||
* should be committed; `false` reverts to the snapshot (e.g. wall too
|
||||
* short, vertex collapsed onto neighbour).
|
||||
* Called on pointer-up. Return `true` if the drag should commit;
|
||||
* `false` reverts to the snapshot (e.g. wall too short, vertex
|
||||
* collapsed onto neighbour).
|
||||
*/
|
||||
canCommit(): boolean
|
||||
/**
|
||||
* Optional atomic-commit hook — mirror of the same field on
|
||||
* `FloorplanMoveTargetSession`. When present, the dispatcher
|
||||
* reverts to the pre-drag baseline (no-op if `apply()` never wrote
|
||||
* to scene), resumes history, then calls `commit()` instead of
|
||||
* re-applying a diff. The session owns the full final write
|
||||
* (typically `applyNodeChanges` or `updateNodes`) plus clearing any
|
||||
* live overrides it published in `apply()`.
|
||||
*/
|
||||
commit?(): void
|
||||
}
|
||||
|
||||
export type FloorplanAffordance<N> = {
|
||||
@@ -493,6 +582,23 @@ export type FloorplanMoveTargetSession = {
|
||||
* area, overlap detected, ...).
|
||||
*/
|
||||
canCommit(): boolean
|
||||
/**
|
||||
* Optional atomic-commit hook. The default overlay path snapshots
|
||||
* each affected node before drag and writes a diff back on commit —
|
||||
* fine for kinds whose commit is a pure position update, but
|
||||
* insufficient when commit needs to also create or delete nodes
|
||||
* (e.g. wall move emits bridge wall creates + collapsed wall deletes
|
||||
* via `planWallMoveJunctions`).
|
||||
*
|
||||
* When present, the overlay reverts to the pre-drag baseline,
|
||||
* resumes history, and calls `commit()` instead of the default
|
||||
* `updateNodes(finalUpdates)`. The session is responsible for the
|
||||
* full final write (typically `applyNodeChanges`) plus any
|
||||
* post-commit selection / metadata. The overlay still emits the
|
||||
* standard place SFX and clears `movingNode` after `commit()`
|
||||
* returns.
|
||||
*/
|
||||
commit?(): void
|
||||
}
|
||||
|
||||
export type FloorplanMoveTarget<N> = (args: {
|
||||
@@ -592,6 +698,18 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
* the legacy `floorplan-panel.tsx` monolith.
|
||||
*/
|
||||
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
|
||||
/**
|
||||
* Which scope the floor-plan layer walks to find instances of this
|
||||
* kind. Default `'level'` — the layer's DFS from the active level id
|
||||
* picks the node up via its parent chain. `'building'` — the kind
|
||||
* lives as a sibling of levels (elevator is the canonical example:
|
||||
* elevators are parented to the *building*, not a level, but the
|
||||
* floor-plan should still surface them for every level inside that
|
||||
* building). For `'building'`-scoped kinds the layer iterates every
|
||||
* instance whose parent matches the active level's building, and
|
||||
* synthesises a `GeometryContext` whose `parent` is the active level.
|
||||
*/
|
||||
floorplanScope?: 'level' | 'building'
|
||||
/**
|
||||
* 2D drag affordances keyed by the string identifier emitted on
|
||||
* `endpoint-handle` (and similar interactive floor-plan primitives) via
|
||||
@@ -622,6 +740,30 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
* unset and rely on the generic overlay path.
|
||||
*/
|
||||
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
|
||||
/**
|
||||
* Optional hook letting a kind project the `useLiveNodeOverrides` map
|
||||
* into a fresh `nodes` snapshot before its `def.floorplan` builder
|
||||
* runs. The floor-plan layer calls this when present and passes the
|
||||
* returned map both as the builder's `ctx` source AND as the
|
||||
* effective node (so the kind's own override lands in `effectiveNode`).
|
||||
*
|
||||
* Used by wall, whose miter joins read sibling walls via
|
||||
* `ctx.siblings`: during a 2D drag the moved wall + its linked
|
||||
* neighbours publish per-frame `{ start, end, curveOffset }`
|
||||
* overrides, and the floor-plan must merge those into every wall
|
||||
* the builder can see — otherwise miter math snaps back to the
|
||||
* committed positions while the cursor moves. Kinds whose previews
|
||||
* are self-contained leave this unset and the layer hands the raw
|
||||
* `nodes` through.
|
||||
*
|
||||
* Return the input `nodes` unchanged when no override is relevant
|
||||
* so the caller can short-circuit.
|
||||
*/
|
||||
floorplanSiblingOverrides?: (args: {
|
||||
nodeId: AnyNodeId
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
liveOverrides: Map<string, Record<string, unknown>>
|
||||
}) => Record<AnyNodeId, AnyNode>
|
||||
system?: SystemContribution
|
||||
tool?: LazyComponent
|
||||
/**
|
||||
@@ -679,6 +821,24 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
* capability too).
|
||||
*/
|
||||
keyboardActions?: KeyboardActions
|
||||
|
||||
/**
|
||||
* In-world resize / move arrows shown when this kind is selected.
|
||||
*
|
||||
* Pure descriptors — no React, no Three.js. The editor's generic
|
||||
* `<NodeArrowHandles>` reads this list and mounts the matching arrow
|
||||
* components with shared drag plumbing, replacing per-kind
|
||||
* `<XxxSideHandles>` files for the common cases.
|
||||
*
|
||||
* Static array, or a function for shape-dependent affordances
|
||||
* (column `crossSection` / `supportStyle`, stair-segment `segmentType`,
|
||||
* curved-vs-straight stairs). See `./handles.ts` for the variant union.
|
||||
*
|
||||
* Bespoke chrome that doesn't fit the descriptor model (wall corner
|
||||
* leader dashes, fence curving, items with `attachTo`) stays as a
|
||||
* custom React component mounted alongside.
|
||||
*/
|
||||
handles?: HandleList<z.infer<S>>
|
||||
}
|
||||
|
||||
export type NodeCategory = 'site' | 'structure' | 'furnish' | 'analysis' | 'utility'
|
||||
@@ -790,6 +950,26 @@ export type Capabilities = {
|
||||
floorPlaced?: FloorPlacedConfig
|
||||
roofAccessory?: RoofAccessoryConfig
|
||||
paint?: PaintCapability
|
||||
/**
|
||||
* Kind is placed by clicking on a wall (door, window). When set, the
|
||||
* floor-plan layer lets wall background clicks pass through during
|
||||
* placement / move-on-wall — the placement tool's `wall:click` event
|
||||
* needs the SVG's `findClosestWallPoint` handler to run; without
|
||||
* this the wall's registry entry would swallow the click via
|
||||
* `handleSelect`. Read by `FloorplanRegistryLayer` when `movingNode`
|
||||
* is set, so the active move can suspend wall selection.
|
||||
*/
|
||||
wallOpeningPlacement?: boolean
|
||||
/**
|
||||
* Instances of this kind contain levels. When such a node is being
|
||||
* moved, the floor-plan layer falls back to the moving node's id as
|
||||
* the ambient building context — so the floor under the cursor keeps
|
||||
* rendering dimmed throughout the gesture even though the explicit
|
||||
* selection may have been cleared as part of the move handoff. Set
|
||||
* on building; future container kinds (e.g. annexes) opt in by
|
||||
* declaring the same flag.
|
||||
*/
|
||||
floorplanLevelContainer?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1086,6 +1266,14 @@ export type SnapServicesLike = unknown
|
||||
|
||||
export type SceneApi = {
|
||||
get: <N extends AnyNode = AnyNode>(id: AnyNodeId) => N | undefined
|
||||
/**
|
||||
* Snapshot of the full nodes record. For descriptors / placement
|
||||
* callbacks that need to walk many siblings or resolve cross-node
|
||||
* structure (elevator level entries, building level chains, etc.)
|
||||
* without N round-trips through `get`. Returns the live reference —
|
||||
* do not mutate.
|
||||
*/
|
||||
nodes: () => Readonly<Record<AnyNodeId, AnyNode>>
|
||||
update: (id: AnyNodeId, patch: Partial<AnyNode>) => void
|
||||
upsert: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId
|
||||
delete: (id: AnyNodeId) => void
|
||||
|
||||
@@ -26,6 +26,7 @@ function makeSpyScene(initial: Record<string, AnyNode> = {}): SceneApi & {
|
||||
const nodes = { ...initial }
|
||||
return {
|
||||
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
||||
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
update: (nid, patch) => {
|
||||
calls.updated.push([nid, patch])
|
||||
const existing = nodes[nid as string]
|
||||
|
||||
@@ -43,6 +43,7 @@ function makeNode(kind: string, idStr: string, parentId: string | null = null):
|
||||
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
|
||||
return {
|
||||
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
||||
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
update: () => {},
|
||||
upsert: () => id(''),
|
||||
delete: () => {},
|
||||
|
||||
@@ -5,6 +5,7 @@ export type LiveNodeOverrides = Record<string, unknown>
|
||||
type LiveNodeOverrideState = {
|
||||
overrides: Map<string, LiveNodeOverrides>
|
||||
set(nodeId: string, values: LiveNodeOverrides): void
|
||||
setMany(entries: ReadonlyArray<readonly [string, LiveNodeOverrides]>): void
|
||||
get(nodeId: string): LiveNodeOverrides | undefined
|
||||
clear(nodeId: string): void
|
||||
clearAll(): void
|
||||
@@ -18,6 +19,19 @@ const useLiveNodeOverrides = create<LiveNodeOverrideState>((set, get) => ({
|
||||
next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values })
|
||||
return { overrides: next }
|
||||
}),
|
||||
// Batch update — one Map clone + one zustand notification regardless
|
||||
// of entry count, so a drag publishing to N linked walls re-renders
|
||||
// subscribers (WallSystem, FloorplanRegistryLayer) once per tick
|
||||
// instead of N+1 times.
|
||||
setMany: (entries) =>
|
||||
set((state) => {
|
||||
if (entries.length === 0) return state
|
||||
const next = new Map(state.overrides)
|
||||
for (const [nodeId, values] of entries) {
|
||||
next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values })
|
||||
}
|
||||
return { overrides: next }
|
||||
}),
|
||||
get: (nodeId) => get().overrides.get(nodeId),
|
||||
clear: (nodeId) =>
|
||||
set((state) => {
|
||||
@@ -28,4 +42,16 @@ const useLiveNodeOverrides = create<LiveNodeOverrideState>((set, get) => ({
|
||||
clearAll: () => set({ overrides: new Map() }),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Merge any live override for `node` into a fresh copy. Spread semantics —
|
||||
* override fields win, untouched fields stay. Returns the input unchanged
|
||||
* when no override exists, so the caller can use the result directly
|
||||
* without an extra "did anything change" check.
|
||||
*/
|
||||
export function getEffectiveNode<T extends { id: string }>(node: T): T {
|
||||
const override = useLiveNodeOverrides.getState().overrides.get(node.id)
|
||||
if (!override || Object.keys(override).length === 0) return node
|
||||
return { ...node, ...override } as T
|
||||
}
|
||||
|
||||
export default useLiveNodeOverrides
|
||||
|
||||
@@ -101,6 +101,88 @@ function getMoveWallRelation(
|
||||
return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction'
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a junction plan to a list of linked walls, producing the per-wall
|
||||
* endpoint updates. Mirrors the 3D `MoveWallTool`'s drag-preview behavior
|
||||
* so the 2D move handler can drive the same scene topology.
|
||||
*
|
||||
* `linkedWallTargetPlans` take precedence over `linkedWallsToMove` — when
|
||||
* the planner emits a target plan for a same-direction-consumed wall the
|
||||
* matchPoint/targetPoint pair encodes the pivot, not the original ↔ next
|
||||
* endpoint mapping.
|
||||
*/
|
||||
export function getLinkedWallUpdates<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
|
||||
linkedWalls: Array<{
|
||||
wall: TWall
|
||||
matchPoint?: WallPlanPoint
|
||||
targetPoint?: WallPlanPoint
|
||||
}>,
|
||||
originalStart: WallPlanPoint,
|
||||
originalEnd: WallPlanPoint,
|
||||
nextStart: WallPlanPoint,
|
||||
nextEnd: WallPlanPoint,
|
||||
): Array<{ id: TWall['id']; start: WallPlanPoint; end: WallPlanPoint }> {
|
||||
return linkedWalls.map(({ wall, matchPoint, targetPoint }) => {
|
||||
if (matchPoint && targetPoint) {
|
||||
return {
|
||||
id: wall.id,
|
||||
start: pointsEqual(wall.start, matchPoint) ? targetPoint : wall.start,
|
||||
end: pointsEqual(wall.end, matchPoint) ? targetPoint : wall.end,
|
||||
}
|
||||
}
|
||||
|
||||
const targetStart = targetPoint ?? nextStart
|
||||
const targetEnd = targetPoint ?? nextEnd
|
||||
|
||||
return {
|
||||
id: wall.id,
|
||||
start: pointsEqual(wall.start, originalStart)
|
||||
? targetStart
|
||||
: pointsEqual(wall.start, originalEnd)
|
||||
? targetEnd
|
||||
: wall.start,
|
||||
end: pointsEqual(wall.end, originalStart)
|
||||
? targetStart
|
||||
: pointsEqual(wall.end, originalEnd)
|
||||
? targetEnd
|
||||
: wall.end,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getPlannedLinkedWallUpdates<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
|
||||
plan: WallMoveJunctionPlan<TWall>,
|
||||
originalStart: WallPlanPoint,
|
||||
originalEnd: WallPlanPoint,
|
||||
nextStart: WallPlanPoint,
|
||||
nextEnd: WallPlanPoint,
|
||||
): Array<{ id: TWall['id']; start: WallPlanPoint; end: WallPlanPoint }> {
|
||||
const movePlans = new Map<
|
||||
TWall['id'],
|
||||
{ wall: TWall; matchPoint?: WallPlanPoint; targetPoint?: WallPlanPoint }
|
||||
>()
|
||||
|
||||
for (const wall of plan.linkedWallsToMove) {
|
||||
movePlans.set(wall.id, { wall })
|
||||
}
|
||||
|
||||
for (const targetPlan of plan.linkedWallTargetPlans) {
|
||||
movePlans.set(targetPlan.wall.id, {
|
||||
wall: targetPlan.wall,
|
||||
matchPoint: targetPlan.originalPoint,
|
||||
targetPoint: targetPlan.targetPoint,
|
||||
})
|
||||
}
|
||||
|
||||
return getLinkedWallUpdates(
|
||||
Array.from(movePlans.values()),
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)
|
||||
}
|
||||
|
||||
export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
|
||||
linkedWalls: TWall[],
|
||||
originalStart: WallPlanPoint,
|
||||
|
||||
@@ -23,7 +23,6 @@ type FloorplanCursorIndicator =
|
||||
|
||||
type FloorplanCursorIndicatorOverlayProps = {
|
||||
cursorPosition: SvgPoint | null
|
||||
cursorAnchorPosition: SvgPoint | null
|
||||
floorplanSelectionTool: FloorplanSelectionTool
|
||||
movingOpeningType: 'door' | 'window' | null
|
||||
isPanning: boolean
|
||||
@@ -35,7 +34,6 @@ type FloorplanCursorIndicatorOverlayProps = {
|
||||
|
||||
export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
|
||||
cursorPosition,
|
||||
cursorAnchorPosition,
|
||||
floorplanSelectionTool,
|
||||
movingOpeningType,
|
||||
isPanning,
|
||||
@@ -81,7 +79,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
|
||||
return null
|
||||
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
|
||||
|
||||
const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition
|
||||
const position = cursorPosition
|
||||
|
||||
if (!(indicator && position) || isPanning) {
|
||||
return null
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
getWallMidpointHandlePoint,
|
||||
nodeRegistry,
|
||||
type SlabNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -29,6 +32,8 @@ import { NodeActionMenu } from '../editor/node-action-menu'
|
||||
* `capabilities.movable`, `def.floorplanMoveTarget`, OR
|
||||
* `def.affordanceTools.move` (slab / ceiling). The
|
||||
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
|
||||
* Walls are excluded — their move is reached via the side-arrow
|
||||
* handles emitted from `def.floorplan`, not via a menu button.
|
||||
* - Add hole (slab + ceiling only): inserts a small default-square
|
||||
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
|
||||
* `handleAddHole` in `floating-action-menu.tsx`.
|
||||
@@ -52,6 +57,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
|
||||
const isRegistryKind = !!def
|
||||
const isVisible = isRegistryKind && !movingNode
|
||||
const isWall = selectedKind === 'wall'
|
||||
|
||||
useEffect(() => {
|
||||
if (!(isVisible && selectedId)) {
|
||||
@@ -60,21 +66,53 @@ export function FloorplanRegistryActionMenu() {
|
||||
}
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
const el = document.querySelector(
|
||||
`[data-floorplan-scene] [data-node-id="${selectedId}"]`,
|
||||
raf = requestAnimationFrame(tick)
|
||||
const sceneEl = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
|
||||
const svgEl = sceneEl?.ownerSVGElement ?? null
|
||||
const ctm = sceneEl?.getScreenCTM() ?? null
|
||||
if (!(sceneEl && svgEl && ctm)) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Walls: anchor at the wall midpoint in screen space so the menu
|
||||
// sits over the centre of the wall (not the top of its screen-axis
|
||||
// bounding box). Menu itself stays horizontal. Read live overrides
|
||||
// too so the anchor tracks the wall during side-arrow / endpoint
|
||||
// drags. For curved walls `getWallMidpointHandlePoint` returns the
|
||||
// apex point on the arc at t=0.5, matching what the renderer draws.
|
||||
if (isWall) {
|
||||
const sceneNode = useScene.getState().nodes[selectedId] as WallNode | undefined
|
||||
if (!sceneNode) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
const overrides = useLiveNodeOverrides.getState().get(selectedId) as
|
||||
| Partial<WallNode>
|
||||
| undefined
|
||||
const wall = (overrides ? { ...sceneNode, ...overrides } : sceneNode) as WallNode
|
||||
const planMid = getWallMidpointHandlePoint(wall)
|
||||
const midPt = svgEl.createSVGPoint()
|
||||
midPt.x = planMid.x
|
||||
midPt.y = planMid.y
|
||||
const midScreen = midPt.matrixTransform(ctm)
|
||||
setPosition({ left: midScreen.x, top: midScreen.y })
|
||||
return
|
||||
}
|
||||
|
||||
const el = sceneEl.querySelector(
|
||||
`[data-node-id="${selectedId}"]`,
|
||||
) as SVGGElement | null
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
// Position centered horizontally, ~12px above the bounding box.
|
||||
setPosition({ left: rect.left + rect.width / 2, top: rect.top - 12 })
|
||||
setPosition({ left: rect.left + rect.width / 2, top: rect.top })
|
||||
} else {
|
||||
setPosition(null)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [isVisible, selectedId])
|
||||
}, [isVisible, selectedId, isWall])
|
||||
|
||||
if (!(isVisible && selectedId && position && def)) return null
|
||||
|
||||
@@ -84,9 +122,11 @@ export function FloorplanRegistryActionMenu() {
|
||||
// Move button is enabled when any of:
|
||||
// - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
|
||||
// - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
|
||||
// - `def.affordanceTools.move` (kind-owned 3D mover — slab / ceiling)
|
||||
// - `def.affordanceTools.move` (kind-owned 3D mover — slab / ceiling / wall)
|
||||
// From the menu's perspective all three are "this kind can move from
|
||||
// the floor plan." The `MoveTool` dispatcher resolves the right path.
|
||||
// the floor plan." The `MoveTool` dispatcher resolves the right path —
|
||||
// walls land on their bespoke `MoveWallTool` (perpendicular slide
|
||||
// with linked-wall cascade) via `affordanceTools.move`.
|
||||
const canMove =
|
||||
!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
|
||||
const canDuplicate = def.capabilities.duplicable !== false
|
||||
@@ -172,7 +212,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
style={{
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
transform: 'translate(-50%, calc(-100% - 32px))',
|
||||
}}
|
||||
>
|
||||
<NodeActionMenu
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
snapPointToGrid,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -15,6 +16,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
|
||||
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
@@ -41,6 +43,7 @@ const GRID_STEP = 0.5
|
||||
export function FloorplanRegistryMoveOverlay() {
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
||||
|
||||
const def = movingNode ? nodeRegistry.get(movingNode.type) : null
|
||||
const isActive = !!movingNode && !!def?.floorplan
|
||||
@@ -140,6 +143,30 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
|
||||
const commitFinalStateOrRevert = () => {
|
||||
const commitValid = session.canCommit()
|
||||
|
||||
// Claim ownership of the drag teardown so the 3D move tool's
|
||||
// unmount-time cleanup skips its restore-from-snapshot — see
|
||||
// `movingNodeOrigin` in `use-editor.tsx`. Set here (before any
|
||||
// `setMovingNode(null)`) so that by the time the 3D effect's
|
||||
// cleanup runs the origin is observable in the store.
|
||||
setMovingNodeOrigin('2d')
|
||||
|
||||
// Sessions with a `commit` hook own their atomic write (e.g.
|
||||
// wall move emits creates + deletes + updates via the junction
|
||||
// planner). For those we still do Phase 1 (revert to baseline)
|
||||
// and Phase 2's resume — but Phase 2's write is delegated, and
|
||||
// we skip the snapshot-diff finalUpdates path.
|
||||
if (commitValid && session.commit) {
|
||||
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
||||
if (historyPaused) {
|
||||
resumeSceneHistory(useScene)
|
||||
historyPaused = false
|
||||
}
|
||||
session.commit()
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
return
|
||||
}
|
||||
|
||||
const sceneState = useScene.getState().nodes
|
||||
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
|
||||
for (const snap of snapshots) {
|
||||
@@ -209,26 +236,21 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
// inside the SVG viewport, including empty grid background.
|
||||
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
|
||||
|
||||
// Apply once more at the pointer-up coords before committing.
|
||||
// Browsers don't guarantee a pointermove fires right before
|
||||
// pointerup — a quick click after a drag can land pointerup a
|
||||
// few pixels past the last pointermove. Without this re-apply,
|
||||
// the commit would freeze the item at the stale pointermove
|
||||
// position, leaving a visible drift between where the user
|
||||
// released the click and where the item lands.
|
||||
const finalPlanPoint = toMeters(event.clientX, event.clientY)
|
||||
if (finalPlanPoint) {
|
||||
hasMovedSinceStart = true
|
||||
session.apply({
|
||||
planPoint: finalPlanPoint,
|
||||
modifiers: {
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Commit using the LAST pointermove's state — no re-apply at
|
||||
// pointer-up coords. A previous version re-applied here to
|
||||
// close a sub-pixel "drift" window when pointer-up fires
|
||||
// without a preceding pointermove, but that re-apply also
|
||||
// re-snaps: if the pointer-up coord crosses a grid boundary
|
||||
// relative to the last pointermove, the snapped result flips
|
||||
// to a different grid cell and the wall (or other moved node)
|
||||
// visibly jumps from where it was painted during the drag to
|
||||
// a different commit position. Trusting the last pointermove
|
||||
// means "what you saw is what gets committed", which is the
|
||||
// UX users expect — at the cost of a sub-pixel drift in the
|
||||
// rare case where the OS fires pointerup with no preceding
|
||||
// pointermove. Modern browsers reliably emit a final
|
||||
// pointermove right before pointerup, so the trade-off lands
|
||||
// on the side of WYSIWYG.
|
||||
|
||||
commitFinalStateOrRevert()
|
||||
setMovingNode(null)
|
||||
@@ -263,18 +285,27 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
// Claim teardown ownership so the 3D move tool's cleanup skips
|
||||
// its own restore — without this, both sides would race to
|
||||
// write the same baseline, harmless but wasteful.
|
||||
setMovingNodeOrigin('2d')
|
||||
// Revert untracked, then resume — no history entry.
|
||||
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
||||
if (historyPaused) {
|
||||
resumeSceneHistory(useScene)
|
||||
historyPaused = false
|
||||
}
|
||||
// Clear any live-transform previews the session wrote (slab /
|
||||
// ceiling 2D move stages a translation delta in
|
||||
// `useLiveTransforms`; without this clear, escape leaves the
|
||||
// 2D layer rendering the polygon at the cancelled delta).
|
||||
// Clear any live previews the session wrote. Slab / ceiling
|
||||
// 2D move stages a translation delta in `useLiveTransforms`;
|
||||
// wall move publishes `{ start, end, ... }` to
|
||||
// `useLiveNodeOverrides`. Either way, leaving them in place
|
||||
// after Esc would freeze the 2D / 3D view at the cancelled
|
||||
// position.
|
||||
const liveTransforms = useLiveTransforms.getState()
|
||||
const liveOverrides = useLiveNodeOverrides.getState()
|
||||
for (const id of session.affectedIds) {
|
||||
useLiveTransforms.getState().clear(id)
|
||||
liveTransforms.clear(id)
|
||||
liveOverrides.clear(id)
|
||||
}
|
||||
// Restore selection cleared by the action menu's Move click.
|
||||
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
|
||||
@@ -288,56 +319,48 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
// Unmount cleanup. Two scenarios when `historyPaused === true`:
|
||||
// Unmount cleanup. `historyPaused === true` here means none of
|
||||
// our terminal paths (commit, Esc) ran in this overlay — they
|
||||
// each call `resumeSceneHistory` and flip the flag.
|
||||
//
|
||||
// - User did at least one 2D apply (`hasMovedSinceStart`) but
|
||||
// never committed — likely a mid-drag unmount. Revert the
|
||||
// untracked writes so we don't leak partial state.
|
||||
// - No 2D apply happened. The legacy `MoveItemContent` (3D
|
||||
// mover) may have committed via `draftNode.commit` just
|
||||
// before this unmount; clobbering that with a blind revert
|
||||
// is the bug — both the rotation and position issues. Skip
|
||||
// the revert and just resume history.
|
||||
// If `movingNodeOrigin === '3d'`, a 3D move tool finalised
|
||||
// while our overlay was still mounted (split view); the live
|
||||
// scene IS the committed state and reverting would stomp it.
|
||||
// Otherwise (origin is `null` or `'2d'`) we own the teardown
|
||||
// and revert any untracked apply() writes back to baseline.
|
||||
//
|
||||
// Additionally, in split view the user may have brushed the
|
||||
// cursor over the floor plan (setting `hasMovedSinceStart`)
|
||||
// and then committed via a 3D mover. The 3D commit writes the
|
||||
// new state to `scene` directly, so by the time this cleanup
|
||||
// runs `snapshots` no longer matches scene state. Reverting
|
||||
// here would stomp the 3D commit. Detect the case by
|
||||
// comparing snapshot fields to current scene state — if they
|
||||
// already differ, an external committer has finalised, leave
|
||||
// it alone.
|
||||
//
|
||||
// Normal 2D commit / Escape paths set `historyPaused = false`
|
||||
// inside `commitFinalStateOrRevert` / `onKey`, so this branch
|
||||
// is skipped there.
|
||||
// The two prior scenarios this block guarded against:
|
||||
// - mid-drag unmount with apply() writes still present
|
||||
// - 3D mover committing via `draftNode.commit` just before
|
||||
// our unmount
|
||||
// are now distinguished by the origin flag — no scene-state
|
||||
// diff heuristic required.
|
||||
if (historyPaused) {
|
||||
if (hasMovedSinceStart) {
|
||||
const currentNodes = useScene.getState().nodes
|
||||
const externallyCommitted = snapshots.some((snap) => {
|
||||
const current = currentNodes[snap.id]
|
||||
if (!current) return false
|
||||
for (const [key, before] of Object.entries(snap.data)) {
|
||||
const after = (current as unknown as Record<string, unknown>)[key]
|
||||
if (!deepEqual(before, after)) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if (!externallyCommitted) {
|
||||
const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
|
||||
if (!finalisedBy3D) {
|
||||
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
||||
}
|
||||
}
|
||||
resumeSceneHistory(useScene)
|
||||
}
|
||||
// Belt-and-suspenders: clear any live-transform previews on
|
||||
// abnormal unmount paths too. Slab / ceiling sessions write
|
||||
// `useLiveTransforms` to drive the smooth drag visual; in pure
|
||||
// 2D view the 3D `MoveSlabTool` cleanup isn't there to clear
|
||||
// it for us.
|
||||
// Belt-and-suspenders: clear any live previews on abnormal
|
||||
// unmount paths too. Slab / ceiling sessions write to
|
||||
// `useLiveTransforms`; wall sessions write to
|
||||
// `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
|
||||
// tool's cleanup isn't there to clear them for us.
|
||||
const liveTransforms = useLiveTransforms.getState()
|
||||
const liveOverrides = useLiveNodeOverrides.getState()
|
||||
for (const id of session.affectedIds) {
|
||||
useLiveTransforms.getState().clear(id)
|
||||
liveTransforms.clear(id)
|
||||
liveOverrides.clear(id)
|
||||
}
|
||||
// Same belt-and-suspenders pattern for the wall bridge ghost
|
||||
// previews — clear unconditionally so Esc / mid-drag unmount /
|
||||
// 3D-takeover paths all end up with no stale ghosts left over.
|
||||
// The wall session's `commit()` already clears them on the
|
||||
// happy path; this just covers the rest.
|
||||
useWallMoveGhosts.getState().clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,7 +433,7 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
entry.removeAttribute('transform')
|
||||
}
|
||||
}, [isActive, movingNode, setMovingNode, hasMoveTarget, def])
|
||||
}, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -25,6 +25,13 @@ export type FloorplanRenderContextValue = {
|
||||
palette: FloorplanPalette
|
||||
/** SVG `<pattern>` id mounted in `<defs>` by the legacy panel for selection hatch fills. */
|
||||
hatchPatternId: string
|
||||
/**
|
||||
* Rotation (degrees) applied to the registry layer's parent `<g>` by the
|
||||
* legacy panel — 90° by default, adjusted by building rotation. Renderers
|
||||
* that emit text labels use this to keep their final on-screen orientation
|
||||
* readable instead of mirroring whatever the parent rotation is.
|
||||
*/
|
||||
sceneRotationDeg: number
|
||||
}
|
||||
|
||||
const FloorplanRenderContext = createContext<FloorplanRenderContextValue | null>(null)
|
||||
@@ -34,10 +41,11 @@ export function FloorplanRenderProvider({
|
||||
unitsPerPixel,
|
||||
palette,
|
||||
hatchPatternId,
|
||||
sceneRotationDeg,
|
||||
}: FloorplanRenderContextValue & { children: ReactNode }) {
|
||||
const value = useMemo<FloorplanRenderContextValue>(
|
||||
() => ({ unitsPerPixel, palette, hatchPatternId }),
|
||||
[unitsPerPixel, palette, hatchPatternId],
|
||||
() => ({ unitsPerPixel, palette, hatchPatternId, sceneRotationDeg }),
|
||||
[unitsPerPixel, palette, hatchPatternId, sceneRotationDeg],
|
||||
)
|
||||
return <FloorplanRenderContext.Provider value={value}>{children}</FloorplanRenderContext.Provider>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
|
||||
|
||||
/**
|
||||
* Renders translucent dashed previews of bridge walls that the wall
|
||||
* junction planner will insert on commit. Mirrors the 3D
|
||||
* `GhostWallPreviewMesh` so 2D and 3D show the same intent mid-drag.
|
||||
*
|
||||
* Subscribes to `useWallMoveGhosts.bridges`; writes happen inside
|
||||
* `wallFloorplanMoveTarget.apply` (cleared on `commit` and by the move
|
||||
* overlay's unmount cleanup as a safety net).
|
||||
*/
|
||||
export const FloorplanWallMoveGhostLayer = memo(function FloorplanWallMoveGhostLayer() {
|
||||
const bridges = useWallMoveGhosts((s) => s.bridges)
|
||||
|
||||
if (bridges.length === 0) return null
|
||||
|
||||
return (
|
||||
<g pointerEvents="none">
|
||||
{bridges.map((bridge) => {
|
||||
// Draw the bridge as a thick stroke at the wall's plan-space
|
||||
// thickness — same convention as the actual wall renderer.
|
||||
// Dashes scale with thickness so they read consistently no
|
||||
// matter the wall size or viewport zoom (no `non-scaling-stroke`
|
||||
// here; we want the line to be N meters thick, not N pixels).
|
||||
const dash = bridge.thickness * 1.4
|
||||
const gap = bridge.thickness * 0.9
|
||||
return (
|
||||
<line
|
||||
key={bridge.id}
|
||||
opacity={0.45}
|
||||
stroke={bridge.color}
|
||||
strokeDasharray={`${dash} ${gap}`}
|
||||
strokeLinecap="butt"
|
||||
strokeWidth={bridge.thickness}
|
||||
x1={bridge.start[0]}
|
||||
x2={bridge.end[0]}
|
||||
y1={bridge.start[1]}
|
||||
y2={bridge.end[1]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -46,6 +46,8 @@ function styleAttrs(g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['ki
|
||||
strokeOpacity?: number
|
||||
opacity?: number
|
||||
vectorEffect?: 'non-scaling-stroke'
|
||||
pointerEvents?: string
|
||||
cursor?: string
|
||||
}
|
||||
return {
|
||||
fill: s.fill ?? 'none',
|
||||
@@ -58,6 +60,8 @@ function styleAttrs(g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['ki
|
||||
strokeOpacity: s.strokeOpacity,
|
||||
opacity: s.opacity,
|
||||
vectorEffect: s.vectorEffect,
|
||||
pointerEvents: s.pointerEvents,
|
||||
style: s.cursor ? { cursor: s.cursor } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPalette,
|
||||
type GeometryContext,
|
||||
kindsWithFloorplanScope,
|
||||
nodeRegistry,
|
||||
pauseSceneHistory,
|
||||
resolveBuildingForLevel,
|
||||
resumeSceneHistory,
|
||||
useInteractive,
|
||||
useLiveNodeOverrides,
|
||||
@@ -101,16 +103,74 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
|
||||
}
|
||||
|
||||
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const levelId = useViewer((s) => s.selection.levelId)
|
||||
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
||||
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
const setHoveredId = useViewer((s) => s.setHoveredId)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
// When a building is being moved, its explicit selection may be
|
||||
// cleared as part of the move handoff. Fall back to the
|
||||
// mid-drag building id so the dimmed floor keeps rendering
|
||||
// throughout the gesture.
|
||||
const movingBuildingId = useEditor((state) => {
|
||||
const moving = state.movingNode
|
||||
if (!moving) return null
|
||||
const def = nodeRegistry.get(moving.type)
|
||||
return def?.capabilities?.floorplanLevelContainer ? moving.id : null
|
||||
})
|
||||
const ambientBuildingSourceId = selectedBuildingId ?? movingBuildingId
|
||||
|
||||
// When only a building is in scope (no specific level), fall back to
|
||||
// its level 0 (or the lowest-indexed level) so the floor still
|
||||
// renders as context — dimmed and non-interactive — instead of
|
||||
// disappearing entirely.
|
||||
const ambientLevelId = useMemo<AnyNodeId | null>(() => {
|
||||
if (selectedLevelId || !ambientBuildingSourceId) return null
|
||||
const building = nodes[ambientBuildingSourceId]
|
||||
if (!building || building.type !== 'building') return null
|
||||
let zero: AnyNodeId | null = null
|
||||
let lowestId: AnyNodeId | null = null
|
||||
let lowestIdx = Number.POSITIVE_INFINITY
|
||||
const childIds = (building as unknown as { children?: AnyNodeId[] }).children ?? []
|
||||
for (const childId of childIds) {
|
||||
const child = nodes[childId]
|
||||
if (child?.type !== 'level') continue
|
||||
if (child.level === 0) {
|
||||
zero = child.id
|
||||
break
|
||||
}
|
||||
if (child.level < lowestIdx) {
|
||||
lowestIdx = child.level
|
||||
lowestId = child.id
|
||||
}
|
||||
}
|
||||
return zero ?? lowestId
|
||||
}, [selectedLevelId, ambientBuildingSourceId, nodes])
|
||||
|
||||
const levelId = selectedLevelId ?? ambientLevelId
|
||||
const isAmbient = !selectedLevelId && !!ambientLevelId
|
||||
const renderCtx = useFloorplanRender()
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
// Door / window placement (both build and move) needs the SVG's
|
||||
// background click handler to run — it finds the closest wall via
|
||||
// `findClosestWallPoint` and emits `wall:click` for the door / window
|
||||
// tool. When the user clicks *on top of* a wall in this mode, the
|
||||
// wall's registry entry would otherwise swallow the click via
|
||||
// `handleClickStop` / `handleSelect`, so the placement never fires.
|
||||
// Pass clicks through in that case.
|
||||
const editorPhase = useEditor((s) => s.phase)
|
||||
const editorMode = useEditor((s) => s.mode)
|
||||
const editorTool = useEditor((s) => s.tool)
|
||||
const isOpeningPlacementActive =
|
||||
(editorPhase === 'structure' &&
|
||||
editorMode === 'build' &&
|
||||
(editorTool === 'door' || editorTool === 'window')) ||
|
||||
(movingNode != null &&
|
||||
!!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
||||
// Subscribe to the live-transforms map ref so the layer re-renders
|
||||
// whenever a 3D mover publishes a per-frame position (see
|
||||
// `usePlacementCoordinator`). Without this the 2D floor plan only
|
||||
@@ -140,6 +200,21 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
setSelection({ selectedIds: [id] })
|
||||
// Setting selection re-renders the entry — the overlay pass mounts
|
||||
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
||||
// pointerdown and click. If the click target ends up on the SVG
|
||||
// background, `<g floorplan-registry-layer onClick=handleClickStop>`
|
||||
// never sees it, and the SVG's `handleBackgroundClick` clears the
|
||||
// selection we just set. Swallow the next click globally to break
|
||||
// that race; the listener removes itself after firing (or after a
|
||||
// safety timeout if no click follows).
|
||||
const swallowClick = (ev: Event) => {
|
||||
ev.stopPropagation()
|
||||
ev.preventDefault()
|
||||
window.removeEventListener('click', swallowClick, true)
|
||||
}
|
||||
window.addEventListener('click', swallowClick, true)
|
||||
setTimeout(() => window.removeEventListener('click', swallowClick, true), 200)
|
||||
},
|
||||
[setSelection],
|
||||
)
|
||||
@@ -232,7 +307,24 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
}
|
||||
}
|
||||
}
|
||||
const ctx = buildContext(effectiveNode, nodes, {
|
||||
// Live-edit overrides: kinds whose `def.floorplan` builder
|
||||
// reads cross-sibling data (wall miters, …) declare a
|
||||
// `def.floorplanSiblingOverrides` hook that projects the
|
||||
// override map into a merged `nodes` snapshot. The merged
|
||||
// copy feeds `buildContext` so `ctx.siblings` reflects the
|
||||
// live cursor positions, and replaces `effectiveNode` so the
|
||||
// kind's own override lands too (covers the case where the
|
||||
// node being rendered is itself the dragged one). Kinds
|
||||
// without the hook hand the raw `nodes` through — most
|
||||
// previews are self-contained.
|
||||
const contextNodes = def?.floorplanSiblingOverrides
|
||||
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
|
||||
: nodes
|
||||
if (contextNodes !== nodes) {
|
||||
const merged = contextNodes[id]
|
||||
if (merged) effectiveNode = merged
|
||||
}
|
||||
const ctx = buildContext(effectiveNode, contextNodes, {
|
||||
selected,
|
||||
highlighted,
|
||||
hovered,
|
||||
@@ -256,6 +348,59 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
|
||||
visit(levelId as AnyNodeId)
|
||||
|
||||
// Building-scoped kinds (`def.floorplanScope === 'building'`) live
|
||||
// as siblings of the level, not under it — the `visit(levelId)` DFS
|
||||
// above doesn't reach them. Walk every node of those kinds whose
|
||||
// parent matches the active level's building, and synthesise a
|
||||
// `GeometryContext` whose `parent` is the active level (so kind
|
||||
// builders that gate on the current floor — e.g. elevator service
|
||||
// range — keep working). Pure registry-driven dispatch: no kind
|
||||
// name appears in this file.
|
||||
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
|
||||
const activeBuildingId = activeLevelNode
|
||||
? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
|
||||
: null
|
||||
if (activeLevelNode && activeBuildingId) {
|
||||
const buildingScopedKinds = kindsWithFloorplanScope('building')
|
||||
const buildingScopedKindSet = new Set(buildingScopedKinds)
|
||||
for (const [id, node] of Object.entries(nodes)) {
|
||||
if (!node || !buildingScopedKindSet.has(node.type)) continue
|
||||
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
|
||||
if (parentId !== activeBuildingId) continue
|
||||
const cid = id as AnyNodeId
|
||||
const def = nodeRegistry.get(node.type)
|
||||
const builder = def?.floorplan
|
||||
if (!builder) continue
|
||||
const selected = selectedIdSet.has(cid)
|
||||
const highlighted = highlightedIdSet.has(cid)
|
||||
const hovered = hoveredId === cid
|
||||
const moving = movingNode?.id === cid
|
||||
const ctx: GeometryContext = {
|
||||
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
|
||||
nodes[rid] as N | undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: activeLevelNode,
|
||||
viewState: renderCtx?.palette
|
||||
? {
|
||||
selected,
|
||||
highlighted,
|
||||
hovered,
|
||||
moving,
|
||||
palette: renderCtx.palette,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
const geometry = (
|
||||
builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null
|
||||
)(node, ctx)
|
||||
if (geometry) {
|
||||
const { base, overlay } = splitFloorplanOverlay(geometry)
|
||||
out.push({ id: cid, node, base, overlay, selected, highlighted })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stable z-order sort. SVG renders in document order — later siblings
|
||||
// paint on top of earlier ones — so anything that should sit *under*
|
||||
// other floor-plan geometry has to come first in the entries array.
|
||||
@@ -269,11 +414,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
levelId,
|
||||
nodes,
|
||||
liveTransforms,
|
||||
liveOverrides,
|
||||
selectedIdSet,
|
||||
highlightedIdSet,
|
||||
hoveredId,
|
||||
movingNode?.id,
|
||||
renderCtx?.palette,
|
||||
interactiveElevators,
|
||||
])
|
||||
|
||||
// ── Generic 2D affordance dispatch ─────────────────────────────────
|
||||
@@ -362,6 +509,25 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
|
||||
const commitValid = drag.session.canCommit()
|
||||
|
||||
// Sessions with a `commit` hook own their atomic write (e.g.
|
||||
// affordances that publish to `useLiveNodeOverrides` during
|
||||
// `apply()` and never touch scene mid-drag). Mirrors the move
|
||||
// overlay's `session.commit` path — revert untracked (no-op when
|
||||
// the session never wrote to scene), resume history, then let
|
||||
// the session do the tracked write.
|
||||
if (commitValid && drag.session.commit) {
|
||||
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
||||
if (drag.historyPaused) {
|
||||
resumeSceneHistory(useScene)
|
||||
drag.historyPaused = false
|
||||
}
|
||||
drag.session.commit()
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
dragRef.current = null
|
||||
setActiveDragId(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the final state BEFORE the revert so we know what to
|
||||
// re-apply post-resume.
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
@@ -395,12 +561,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
} else {
|
||||
// Either no net change or canCommit() rejected — revert and
|
||||
// resume without committing.
|
||||
// resume without committing. Also clear any live overrides
|
||||
// the session published (no-op when the session writes to
|
||||
// scene directly).
|
||||
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
||||
if (drag.historyPaused) {
|
||||
resumeSceneHistory(useScene)
|
||||
drag.historyPaused = false
|
||||
}
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
for (const id of drag.session.affectedIds) overrides.clear(id)
|
||||
}
|
||||
|
||||
dragRef.current = null
|
||||
@@ -417,6 +587,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
resumeSceneHistory(useScene)
|
||||
drag.historyPaused = false
|
||||
}
|
||||
// Drop any live overrides the session may have published. No-op
|
||||
// for affordances whose `apply()` writes straight to scene; the
|
||||
// override-routed sessions (wall endpoint, wall curve) rely on
|
||||
// this to revert cleanly.
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
for (const id of drag.session.affectedIds) overrides.clear(id)
|
||||
|
||||
dragRef.current = null
|
||||
setActiveDragId(null)
|
||||
@@ -430,13 +606,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerCancel)
|
||||
// Component unmounted mid-drag — restore the baseline and unpause
|
||||
// history so we don't leak a paused store across mounts.
|
||||
// history so we don't leak a paused store across mounts. Also
|
||||
// drop any live overrides the session published so the next
|
||||
// mount doesn't render at the cancelled position.
|
||||
const drag = dragRef.current
|
||||
if (drag) {
|
||||
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
||||
if (drag.historyPaused) {
|
||||
resumeSceneHistory(useScene)
|
||||
}
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
for (const id of drag.session.affectedIds) overrides.clear(id)
|
||||
dragRef.current = null
|
||||
}
|
||||
}
|
||||
@@ -452,8 +632,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
className="floorplan-registry-entry"
|
||||
data-node-id={id}
|
||||
key={key}
|
||||
onClick={handleClickStop}
|
||||
onPointerDown={(e) => handleSelect(id, e)}
|
||||
onClick={isOpeningPlacementActive ? undefined : handleClickStop}
|
||||
onPointerDown={isOpeningPlacementActive ? undefined : (e) => handleSelect(id, e)}
|
||||
// Mirror the sidebar tree nodes' hover wiring — `useViewer.
|
||||
// hoveredId` drives the highlight halo in 3D as well as the
|
||||
// wall / fence floor-plan hover stroke. Setting it on
|
||||
@@ -490,6 +670,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
setMovingNode(node as never)
|
||||
}}
|
||||
palette={palette}
|
||||
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
||||
unitsPerPixel={unitsPerPixel}
|
||||
/>
|
||||
</g>
|
||||
@@ -510,7 +691,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
// appear to "deselect themselves a fraction of a second after
|
||||
// clicking." Scoped to `onClick` so hover / drag / pointer events
|
||||
// still propagate normally inside the registry tree.
|
||||
<g className="floorplan-registry-layer" onClick={handleClickStop}>
|
||||
<g
|
||||
className="floorplan-registry-layer"
|
||||
onClick={isOpeningPlacementActive ? undefined : handleClickStop}
|
||||
opacity={isAmbient ? 0.3 : undefined}
|
||||
style={isAmbient ? { pointerEvents: 'none' } : undefined}
|
||||
>
|
||||
{/* Base pass — rank-sorted body geometry (polygons, paths, fills,
|
||||
strokes, hatches). Lower-rank kinds (zones) paint first so
|
||||
higher-rank kinds (slabs, then walls / items / shelves) layer
|
||||
@@ -544,6 +730,7 @@ function InteractiveGeometry({
|
||||
hoveredHandleId,
|
||||
activeDragId,
|
||||
nodeId,
|
||||
sceneRotationDeg,
|
||||
onHandleHoverChange,
|
||||
onHandlePointerDown,
|
||||
onMoveHandlePointerDown,
|
||||
@@ -555,6 +742,7 @@ function InteractiveGeometry({
|
||||
hoveredHandleId: string | null
|
||||
activeDragId: string | null
|
||||
nodeId: AnyNodeId
|
||||
sceneRotationDeg: number
|
||||
onHandleHoverChange: (id: string | null) => void
|
||||
onHandlePointerDown: (
|
||||
affordance: string,
|
||||
@@ -591,7 +779,7 @@ function InteractiveGeometry({
|
||||
return (
|
||||
<line
|
||||
key={keyHint}
|
||||
pointerEvents="stroke"
|
||||
pointerEvents={g.pointerEvents ?? 'stroke'}
|
||||
stroke="transparent"
|
||||
strokeLinecap="round"
|
||||
strokeWidth={g.strokeWidthPx * unitsPerPixel}
|
||||
@@ -705,15 +893,19 @@ function InteractiveGeometry({
|
||||
if (!palette) return <></>
|
||||
const moveHandleId = `${nodeId}:move`
|
||||
const isHovered = hoveredHandleId === moveHandleId
|
||||
// Move dots are visually bigger than endpoint handles — the
|
||||
// legacy prod render uses ~13px outer / ~6px dot. Endpoint
|
||||
// handles top out at 8/9px because there are usually two per
|
||||
// wall + linked walls + curve handle nearby; the move dot is
|
||||
// a singleton centerpiece so it can afford the extra weight.
|
||||
const baseRadiusPx = 13
|
||||
const hoverRadiusPx = 15
|
||||
const outerRadius = (isHovered ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel
|
||||
const dotRadius = 6 * unitsPerPixel
|
||||
// World-relative sizing: the move dot is anchored to the door,
|
||||
// not to the screen, so it grows when the user zooms in and
|
||||
// shrinks when they zoom out — same scaling rule as the door
|
||||
// footprint itself. Sizes are tuned for a ~0.9 m door at default
|
||||
// zoom; the ratios match the legacy 13/15/6/16/7/18 px stack.
|
||||
const baseRadius = 0.1
|
||||
const hoverRadius = 0.115
|
||||
const outerRadius = isHovered ? hoverRadius : baseRadius
|
||||
const dotRadius = 0.045
|
||||
const fillStroke = 0.005
|
||||
const glowStroke = 0.12
|
||||
const ringStroke = 0.055
|
||||
const hitStroke = 0.14
|
||||
// Same 5-circle stack as the orange endpoint dot — hover glow +
|
||||
// hover ring + filled outer + inner dot + transparent hit. On
|
||||
// pointer-down, the layer calls `setMovingNode(node)`, which
|
||||
@@ -734,9 +926,8 @@ function InteractiveGeometry({
|
||||
r={outerRadius}
|
||||
stroke={palette.endpointHandleHoverStroke}
|
||||
strokeOpacity={0.16}
|
||||
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * unitsPerPixel}
|
||||
strokeWidth={glowStroke}
|
||||
style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
cx={g.point[0]}
|
||||
@@ -746,9 +937,8 @@ function InteractiveGeometry({
|
||||
r={outerRadius}
|
||||
stroke={palette.endpointHandleHoverStroke}
|
||||
strokeOpacity={0.52}
|
||||
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
|
||||
strokeWidth={ringStroke}
|
||||
style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
cx={g.point[0]}
|
||||
@@ -758,8 +948,7 @@ function InteractiveGeometry({
|
||||
pointerEvents="none"
|
||||
r={outerRadius}
|
||||
stroke={palette.endpointHandleStroke}
|
||||
strokeWidth="0.05"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeWidth={fillStroke}
|
||||
/>
|
||||
<circle
|
||||
cx={g.point[0]}
|
||||
@@ -767,7 +956,6 @@ function InteractiveGeometry({
|
||||
fill={palette.endpointHandleStroke}
|
||||
pointerEvents="none"
|
||||
r={dotRadius}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
cx={g.point[0]}
|
||||
@@ -777,10 +965,156 @@ function InteractiveGeometry({
|
||||
pointerEvents="all"
|
||||
r={outerRadius}
|
||||
stroke="transparent"
|
||||
strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel}
|
||||
strokeWidth={hitStroke}
|
||||
style={{ cursor: 'move' }}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
case 'rotate-arrow': {
|
||||
if (!palette) return <></>
|
||||
// 2D counterpart of the 3D `arc-resize` rotate gizmo. Local
|
||||
// frame: +X is the radial-outward direction (away from the
|
||||
// pivot); the arc bows in that direction with arrowheads on
|
||||
// each end pointing tangentially in opposite directions —
|
||||
// "rotate either way."
|
||||
const handleId = makeHandleId(nodeId, g.payload)
|
||||
const isHovered = hoveredHandleId === handleId
|
||||
// Arc geometry (all values precomputed for a 72° arc of
|
||||
// radius 0.13 — comparable footprint to `move-arrow`).
|
||||
const R = 0.13
|
||||
const halfSpan = Math.PI / 5
|
||||
const cosH = Math.cos(halfSpan)
|
||||
const sinH = Math.sin(halfSpan)
|
||||
const endY = R * sinH
|
||||
const headLen = 0.06
|
||||
const headHalfBase = 0.045
|
||||
// End-1 (top) arrowhead — tip along CCW tangent.
|
||||
const t1x = -sinH * headLen
|
||||
const t1y = endY + cosH * headLen
|
||||
const b1ax = cosH * headHalfBase
|
||||
const b1ay = endY + sinH * headHalfBase
|
||||
const b1bx = -cosH * headHalfBase
|
||||
const b1by = endY - sinH * headHalfBase
|
||||
// End-2 (bottom) arrowhead — mirror of End-1.
|
||||
const t2x = -sinH * headLen
|
||||
const t2y = -endY - cosH * headLen
|
||||
const b2ax = cosH * headHalfBase
|
||||
const b2ay = -endY - sinH * headHalfBase
|
||||
const b2bx = -cosH * headHalfBase
|
||||
const b2by = -endY + sinH * headHalfBase
|
||||
const arcPath = `M 0 ${-endY} A ${R} ${R} 0 0 1 0 ${endY}`
|
||||
const head1 = `M ${t1x} ${t1y} L ${b1ax} ${b1ay} L ${b1bx} ${b1by} Z`
|
||||
const head2 = `M ${t2x} ${t2y} L ${b2ax} ${b2ay} L ${b2bx} ${b2by} Z`
|
||||
const fill = isHovered ? '#a5b4fc' : '#8381ed'
|
||||
const strokeWidthPx = isHovered ? 2.4 : 1.8
|
||||
const angleDeg = (g.angle * 180) / Math.PI
|
||||
const affordance = g.affordance
|
||||
const payload = g.payload
|
||||
return (
|
||||
<g
|
||||
key={keyHint}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${angleDeg})`}
|
||||
>
|
||||
<path
|
||||
d={arcPath}
|
||||
fill="none"
|
||||
pointerEvents="none"
|
||||
stroke={fill}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={strokeWidthPx}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path d={head1} fill={fill} pointerEvents="none" />
|
||||
<path d={head2} fill={fill} pointerEvents="none" />
|
||||
{/* Hit target — fat invisible stroke along the arc + filled
|
||||
triangles at the heads so the user can grab anywhere on
|
||||
the visible icon. */}
|
||||
<path
|
||||
d={arcPath}
|
||||
fill="none"
|
||||
onPointerDown={(e) =>
|
||||
onHandlePointerDown(affordance, payload, e as ReactPointerEvent<SVGPathElement>)
|
||||
}
|
||||
onPointerEnter={() => onHandleHoverChange(handleId)}
|
||||
onPointerLeave={() => onHandleHoverChange(null)}
|
||||
pointerEvents="stroke"
|
||||
stroke="transparent"
|
||||
strokeWidth={0.06}
|
||||
style={{ cursor: 'grab' }}
|
||||
/>
|
||||
<path
|
||||
d={`${head1} ${head2}`}
|
||||
fill="transparent"
|
||||
onPointerDown={(e) =>
|
||||
onHandlePointerDown(affordance, payload, e as ReactPointerEvent<SVGPathElement>)
|
||||
}
|
||||
onPointerEnter={() => onHandleHoverChange(handleId)}
|
||||
onPointerLeave={() => onHandleHoverChange(null)}
|
||||
pointerEvents="fill"
|
||||
style={{ cursor: 'grab' }}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
case 'move-arrow': {
|
||||
if (!palette) return <></>
|
||||
// Affordance-routed arrows (door width-resize) get a per-payload
|
||||
// handle id so each side can hover independently; default
|
||||
// (move-flow) arrows share the node's :move id like the dot.
|
||||
const handleId = g.affordance ? makeHandleId(nodeId, g.payload) : `${nodeId}:move`
|
||||
const isHovered = hoveredHandleId === handleId
|
||||
// Arrow geometry in plan units (meters) — scales with the scene
|
||||
// so it shrinks on zoom-out and grows on zoom-in, matching the
|
||||
// wall it accompanies. Composed of a rectangular shaft + triangular
|
||||
// head, drawn as a single path for a clean fill + stroke outline.
|
||||
const sl = 0.1 // shaft length (shortened body)
|
||||
const hl = 0.12 // head length
|
||||
const sh = 0.04 // shaft half-height
|
||||
const hh = 0.1 // head half-height
|
||||
// Inset the shaft start so the arrow sits a little off the wall
|
||||
// body (matches the 3D `HANDLE_OFFSET`).
|
||||
const bi = 0.03 // base inset
|
||||
const arrowD = `M ${bi},${-sh} L ${bi + sl},${-sh} L ${bi + sl},${-hh} L ${bi + sl + hl},0 L ${bi + sl},${hh} L ${bi + sl},${sh} L ${bi},${sh} Z`
|
||||
// Indigo palette to match the 3D `WallMoveSideHandles` arrows
|
||||
// (`ARROW_COLOR` / `ARROW_HOVER_COLOR`) and the corner-sphere
|
||||
// accent in `floating-action-menu.tsx`.
|
||||
const fill = isHovered ? '#a5b4fc' : '#8381ed'
|
||||
const angleDeg = (g.angle * 180) / Math.PI
|
||||
const cursor = g.affordance ? 'ew-resize' : 'move'
|
||||
const affordance = g.affordance
|
||||
const payload = g.payload
|
||||
// No hover-grow: a scaling transform would enlarge the hit area
|
||||
// too, letting clicks just outside the visible arrow still start
|
||||
// a drag. Hover feedback is colour-only so the click region
|
||||
// always matches the painted arrow shape exactly.
|
||||
return (
|
||||
<g
|
||||
key={keyHint}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${angleDeg})`}
|
||||
>
|
||||
<path d={arrowD} fill={fill} pointerEvents="none" />
|
||||
<path
|
||||
d={arrowD}
|
||||
fill="transparent"
|
||||
onPointerDown={(e) => {
|
||||
if (affordance) {
|
||||
onHandlePointerDown(
|
||||
affordance,
|
||||
payload,
|
||||
e as ReactPointerEvent<SVGGElement>,
|
||||
)
|
||||
} else {
|
||||
onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)
|
||||
}
|
||||
}}
|
||||
onPointerEnter={() => onHandleHoverChange(handleId)}
|
||||
onPointerLeave={() => onHandleHoverChange(null)}
|
||||
pointerEvents="fill"
|
||||
style={{ cursor }}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -937,11 +1271,19 @@ function InteractiveGeometry({
|
||||
}
|
||||
case 'dimension-label': {
|
||||
if (!palette) return <></>
|
||||
// Flip the label upright if it would otherwise be upside-down
|
||||
// (legacy floorplan-panel.tsx does the same — see line ~2548).
|
||||
// Flip the label upright relative to the SCREEN, not the local
|
||||
// coord system. The registry layer's parent `<g>` is rotated by
|
||||
// `sceneRotationDeg` (default 90° in the floor plan), so a label
|
||||
// we draw "upright" in local coords ends up sideways on screen.
|
||||
// Combine local angle + scene rotation, normalise to (-180, 180],
|
||||
// and flip by 180° if it falls outside (-90, 90] — that keeps
|
||||
// text reading left-to-right, top-to-bottom regardless of the
|
||||
// building's orientation.
|
||||
let degrees = (g.angle * 180) / Math.PI
|
||||
if (degrees > 90) degrees -= 180
|
||||
else if (degrees <= -90) degrees += 180
|
||||
let screenDegrees = degrees + sceneRotationDeg
|
||||
screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
|
||||
if (screenDegrees > 90) degrees -= 180
|
||||
else if (screenDegrees <= -90) degrees += 180
|
||||
|
||||
const padX = unitsPerPixel * 6
|
||||
const padY = unitsPerPixel * 3
|
||||
@@ -1220,6 +1562,8 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
||||
'midpoint-handle',
|
||||
'edge-handle',
|
||||
'move-handle',
|
||||
'move-arrow',
|
||||
'rotate-arrow',
|
||||
'dimension',
|
||||
'dimension-label',
|
||||
])
|
||||
|
||||
@@ -139,9 +139,15 @@ export const CustomCameraControls = () => {
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
||||
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
||||
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
||||
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
|
||||
const isInteracting = Boolean(
|
||||
tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive,
|
||||
tool ||
|
||||
movingNode ||
|
||||
movingWallEndpoint ||
|
||||
movingFenceEndpoint ||
|
||||
activeHandleDrag ||
|
||||
isBoxSelectActive,
|
||||
)
|
||||
const touches = useMemo(() => {
|
||||
const twoFingerAction =
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FenceNode,
|
||||
generateId,
|
||||
ItemNode,
|
||||
isRegistryMovable,
|
||||
isRegistrySelectable,
|
||||
nodeRegistry,
|
||||
RoofSegmentNode,
|
||||
@@ -25,8 +26,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { Move } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
@@ -53,6 +53,60 @@ const ALLOWED_TYPES = [
|
||||
const DELETE_ONLY_TYPES: string[] = []
|
||||
const HOLE_TYPES = ['slab', 'ceiling']
|
||||
|
||||
// Menu scales with camera zoom so it feels anchored to the object, but is
|
||||
// clamped on both ends so it stays readable when zoomed way out and doesn't
|
||||
// dominate the screen when zoomed in close. Reference values are picked so
|
||||
// scale = 1 lands near the editor's default framing.
|
||||
const MIN_MENU_SCALE = 0.5
|
||||
// Cap at 1 so zooming in doesn't grow the menu past its default pixel size —
|
||||
// only zoom-out shrinks it (down to MIN_MENU_SCALE).
|
||||
const MAX_MENU_SCALE = 1
|
||||
const REF_ORTHO_ZOOM = 20
|
||||
const REF_CAMERA_DISTANCE = 12
|
||||
|
||||
// World-space Y distance from a node's bbox top to the floating menu anchor.
|
||||
// Per-type because in-world chrome above the node (height-resize arrows,
|
||||
// measurement labels) varies in vertical reach.
|
||||
// `EXTRA_MENU_LIFT` is a uniform global nudge — easier to tune one
|
||||
// constant than to bump every per-type entry below.
|
||||
const EXTRA_MENU_LIFT = 0.35
|
||||
const MENU_Y_OFFSET_DEFAULT = 0.3
|
||||
const MENU_Y_OFFSETS: Record<string, number> = {
|
||||
wall: 0.5,
|
||||
door: 0.6,
|
||||
window: 0.6,
|
||||
column: 0.6,
|
||||
// Fence: clears the height-resize arrow (sits at fence.height + 0.45)
|
||||
// plus the chevron's own visual size, so the menu floats just above it.
|
||||
fence: 1.05,
|
||||
// Elevator: clears the cab-height arrow which sits above the SHAFT
|
||||
// top (resolved through level entries), so the menu floats above it.
|
||||
elevator: 0.9,
|
||||
stair: 0.2,
|
||||
'stair-stair': 1.1,
|
||||
'stair-landing': 0.9,
|
||||
// Slab: clears the height arrow that sits at elevation + 0.22 plus the
|
||||
// chevron's own visual reach, so the menu floats just above it.
|
||||
slab: 0.7,
|
||||
// Ceiling: clears the upward height arrow that sits ~0.22 above the
|
||||
// ceiling plane, plus extra headroom so the menu doesn't crowd the
|
||||
// chevron at any zoom level.
|
||||
ceiling: 1.0,
|
||||
// Shelf: clears the height arrow that sits at shelf.height + 0.22
|
||||
// plus the chevron's visual reach.
|
||||
shelf: 0.6,
|
||||
}
|
||||
|
||||
function getMenuYOffset(node: AnyNode | null): number {
|
||||
if (!node) return MENU_Y_OFFSET_DEFAULT + EXTRA_MENU_LIFT
|
||||
if (node.type === 'stair-segment') {
|
||||
return (
|
||||
(MENU_Y_OFFSETS[`stair-${node.segmentType}`] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
||||
)
|
||||
}
|
||||
return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
||||
}
|
||||
|
||||
export function FloatingActionMenu() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
@@ -62,17 +116,13 @@ export function FloatingActionMenu() {
|
||||
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
||||
const curvingFence = useEditor((s) => s.curvingFence)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
|
||||
const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||
|
||||
const groupRef = useRef<THREE.Group>(null)
|
||||
const startEndpointGroupRef = useRef<THREE.Group>(null)
|
||||
const endEndpointGroupRef = useRef<THREE.Group>(null)
|
||||
const [altPressed, setAltPressed] = useState(false)
|
||||
const menuScaleRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Only show for single selection of specific types
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||
@@ -104,116 +154,37 @@ export function FloatingActionMenu() {
|
||||
})
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Alt') {
|
||||
setAltPressed(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Alt') {
|
||||
setAltPressed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
setAltPressed(false)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('blur', handleBlur)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', handleBlur)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useFrame(() => {
|
||||
useFrame((state) => {
|
||||
if (!(selectedId && isValidType && groupRef.current)) return
|
||||
|
||||
// Scale the HTML menu with camera zoom (ortho) or inverse distance
|
||||
// (perspective) so it feels anchored to the world, clamped on both ends
|
||||
// so it stays readable at extreme zoom-out and doesn't fill the screen
|
||||
// when zoomed in close.
|
||||
if (menuScaleRef.current) {
|
||||
const raw =
|
||||
state.camera instanceof THREE.OrthographicCamera
|
||||
? state.camera.zoom / REF_ORTHO_ZOOM
|
||||
: REF_CAMERA_DISTANCE /
|
||||
Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
|
||||
const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
|
||||
menuScaleRef.current.style.transform = `scale(${scale})`
|
||||
}
|
||||
|
||||
const obj = sceneRegistry.nodes.get(selectedId)
|
||||
if (obj) {
|
||||
// Calculate bounding box in world space
|
||||
const box = new THREE.Box3().setFromObject(obj)
|
||||
if (!box.isEmpty()) {
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
// Position above the object, with extra offset for walls/slabs to avoid covering measurement labels
|
||||
const isStructural = node && [...DELETE_ONLY_TYPES, ...HOLE_TYPES].includes(node.type)
|
||||
const yOffset = isStructural ? 0.8 : 0.3
|
||||
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
|
||||
// Position above the object. Per-type offsets clear each kind's
|
||||
// in-world chrome (height-resize arrows, measurement labels).
|
||||
groupRef.current.position.set(center.x, box.max.y + getMenuYOffset(node), center.z)
|
||||
}
|
||||
|
||||
if (node?.type === 'wall' || node?.type === 'fence') {
|
||||
const segment = node as WallNode | FenceNode
|
||||
const endpointYOffset = 0.35
|
||||
const startWorld =
|
||||
node.type === 'wall'
|
||||
? obj.localToWorld(new THREE.Vector3(0, 0, 0))
|
||||
: obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
|
||||
const endWorld =
|
||||
node.type === 'wall'
|
||||
? obj.localToWorld(
|
||||
new THREE.Vector3(
|
||||
Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
)
|
||||
: obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
|
||||
|
||||
if (startEndpointGroupRef.current) {
|
||||
startEndpointGroupRef.current.position.set(
|
||||
startWorld.x,
|
||||
startWorld.y + endpointYOffset,
|
||||
startWorld.z,
|
||||
)
|
||||
}
|
||||
if (endEndpointGroupRef.current) {
|
||||
endEndpointGroupRef.current.position.set(
|
||||
endWorld.x,
|
||||
endWorld.y + endpointYOffset,
|
||||
endWorld.z,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handleMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
if (
|
||||
node.type === 'item' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'door' ||
|
||||
node.type === 'elevator' ||
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'spawn' ||
|
||||
node.type === 'roof' ||
|
||||
node.type === 'roof-segment' ||
|
||||
node.type === 'stair' ||
|
||||
node.type === 'stair-segment' ||
|
||||
// Registry-driven kinds default to movable; MoveTool dispatches them
|
||||
// to MoveRegistryNodeTool. Phase 4 reads `capabilities.movable` to
|
||||
// gate this instead of the unconditional OR.
|
||||
isRegistrySelectable(node.type)
|
||||
) {
|
||||
setMovingNode(node as any)
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
},
|
||||
[node, setMovingNode, setSelection],
|
||||
)
|
||||
const handleCurve = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -231,23 +202,16 @@ export function FloatingActionMenu() {
|
||||
},
|
||||
[canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
|
||||
)
|
||||
const handleEndpointMove = useCallback(
|
||||
(endpoint: 'start' | 'end', e: React.MouseEvent) => {
|
||||
const handleMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
if (node.type === 'wall') {
|
||||
setMovingWallEndpoint({ wall: node, endpoint })
|
||||
} else if (node.type === 'fence') {
|
||||
setMovingFenceEndpoint({ fence: node, endpoint })
|
||||
} else {
|
||||
return
|
||||
}
|
||||
setMovingNode(node as any)
|
||||
setSelection({ selectedIds: [] })
|
||||
},
|
||||
[node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
|
||||
[node, setMovingNode, setSelection],
|
||||
)
|
||||
|
||||
const handleDuplicate = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -474,6 +438,7 @@ export function FloatingActionMenu() {
|
||||
}}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
|
||||
<NodeActionMenu
|
||||
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
||||
onCurve={
|
||||
@@ -481,6 +446,15 @@ export function FloatingActionMenu() {
|
||||
? handleCurve
|
||||
: undefined
|
||||
}
|
||||
onMove={
|
||||
// Registry-driven: any kind that declares
|
||||
// `capabilities.movable`, a `floorplanMoveTarget`, or a
|
||||
// 3D `affordanceTools.move` mover gets the Move button.
|
||||
// Replaces the previous 13-arm `node?.type === '…'`
|
||||
// chain so adding a new movable kind doesn't touch this
|
||||
// file.
|
||||
node && isRegistryMovable(node.type) ? handleMove : undefined
|
||||
}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={
|
||||
node &&
|
||||
@@ -490,75 +464,12 @@ export function FloatingActionMenu() {
|
||||
? handleDuplicate
|
||||
: undefined
|
||||
}
|
||||
onMove={
|
||||
node &&
|
||||
node.type !== 'wall' &&
|
||||
node.type !== 'fence' &&
|
||||
!DELETE_ONLY_TYPES.includes(node.type)
|
||||
? handleMove
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
{(node?.type === 'wall' || node?.type === 'fence') && (
|
||||
<>
|
||||
<group ref={startEndpointGroupRef}>
|
||||
<Html
|
||||
center
|
||||
style={{ pointerEvents: 'auto', touchAction: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<button
|
||||
aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
|
||||
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
||||
altPressed
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
}`}
|
||||
onClick={(e) => handleEndpointMove('start', e)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title={
|
||||
node.type === 'wall'
|
||||
? 'Move wall start (Alt to detach)'
|
||||
: 'Move fence start (Alt to detach)'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
</button>
|
||||
</Html>
|
||||
</group>
|
||||
<group ref={endEndpointGroupRef}>
|
||||
<Html
|
||||
center
|
||||
style={{ pointerEvents: 'auto', touchAction: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<button
|
||||
aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
|
||||
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
||||
altPressed
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
}`}
|
||||
onClick={(e) => handleEndpointMove('end', e)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title={
|
||||
node.type === 'wall'
|
||||
? 'Move wall end (Alt to detach)'
|
||||
: 'Move fence end (Alt to detach)'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
</button>
|
||||
</Html>
|
||||
</group>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
type FloorplanRenderContextValue,
|
||||
FloorplanRenderProvider,
|
||||
} from '../editor-2d/floorplan-render-context'
|
||||
import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer'
|
||||
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
|
||||
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
|
||||
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
|
||||
@@ -87,6 +89,12 @@ import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-laye
|
||||
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import { snapToHalf } from '../tools/item/placement-math'
|
||||
import {
|
||||
formatAngleRadians,
|
||||
getAngleArcToSegmentReference,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
} from '../tools/shared/segment-angle'
|
||||
import {
|
||||
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
||||
DEFAULT_STAIR_FILL_TO_FLOOR,
|
||||
@@ -98,8 +106,9 @@ import {
|
||||
} from '../tools/stair/stair-defaults'
|
||||
import {
|
||||
createWallOnCurrentLevel,
|
||||
isWallLongEnough,
|
||||
isSegmentLongEnough,
|
||||
snapWallDraftPoint,
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '../tools/wall/wall-drafting'
|
||||
@@ -2025,6 +2034,168 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin
|
||||
}
|
||||
}
|
||||
|
||||
type DraftWallMeasurement = {
|
||||
lengthLabel: string
|
||||
midpoint: WallPlanPoint
|
||||
direction: WallPlanPoint
|
||||
angleLabels: {
|
||||
id: string
|
||||
label: string
|
||||
center: WallPlanPoint
|
||||
radius: number
|
||||
startAngle: number
|
||||
endAngle: number
|
||||
midAngle: number
|
||||
}[]
|
||||
}
|
||||
|
||||
function FloorplanDraftWallMeasurement({
|
||||
measurement,
|
||||
measurementStroke,
|
||||
labelBackground,
|
||||
labelText,
|
||||
sceneRotationDeg,
|
||||
unitsPerPixel,
|
||||
}: {
|
||||
measurement: DraftWallMeasurement
|
||||
measurementStroke: string
|
||||
labelBackground: string
|
||||
labelText: string
|
||||
sceneRotationDeg: number
|
||||
unitsPerPixel: number
|
||||
}) {
|
||||
const stroke = measurementStroke
|
||||
const labelBg = labelBackground
|
||||
|
||||
const upx = unitsPerPixel
|
||||
const fontSize = Math.max(upx * 10, 0.08)
|
||||
const padX = upx * 6
|
||||
const padY = upx * 3
|
||||
|
||||
// Length plate: rotates to follow the wall direction, but flips 180°
|
||||
// when its on-screen orientation would read upside-down (same trick as
|
||||
// `floorplan-registry-layer.tsx` for dimension labels).
|
||||
const wallAngleDeg =
|
||||
(Math.atan2(measurement.direction[1], measurement.direction[0]) * 180) / Math.PI
|
||||
let labelAngleDeg = wallAngleDeg
|
||||
let screenDeg = wallAngleDeg + sceneRotationDeg
|
||||
screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
|
||||
if (screenDeg > 90) labelAngleDeg -= 180
|
||||
else if (screenDeg <= -90) labelAngleDeg += 180
|
||||
|
||||
// Push the plate perpendicular to the wall so the dashed footprint
|
||||
// stays visible underneath.
|
||||
const perpX = -measurement.direction[1]
|
||||
const perpY = measurement.direction[0]
|
||||
const offset = upx * 18
|
||||
const cx = measurement.midpoint[0] + perpX * offset
|
||||
const cy = measurement.midpoint[1] + perpY * offset
|
||||
|
||||
const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2
|
||||
const lengthPlateW = lengthTextWidth + padX * 2
|
||||
const lengthPlateH = fontSize + padY * 2
|
||||
|
||||
const arcSampleCount = 32
|
||||
|
||||
return (
|
||||
<g pointerEvents="none">
|
||||
<g transform={`translate(${cx} ${cy}) rotate(${labelAngleDeg})`}>
|
||||
<rect
|
||||
fill={labelBg}
|
||||
height={lengthPlateH}
|
||||
opacity={0.92}
|
||||
rx={upx * 3}
|
||||
ry={upx * 3}
|
||||
stroke={stroke}
|
||||
strokeWidth={upx * 0.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
width={lengthPlateW}
|
||||
x={-lengthPlateW / 2}
|
||||
y={-lengthPlateH / 2}
|
||||
/>
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
fill={labelText}
|
||||
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||
fontSize={fontSize}
|
||||
fontWeight={600}
|
||||
textAnchor="middle"
|
||||
x={0}
|
||||
y={0}
|
||||
>
|
||||
{measurement.lengthLabel}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{measurement.angleLabels.map((arc) => {
|
||||
// Sample the arc as a polyline — avoids the SVG arc command's
|
||||
// sweep-flag direction quirks across negative/positive sweeps.
|
||||
const points: string[] = []
|
||||
for (let i = 0; i <= arcSampleCount; i += 1) {
|
||||
const t = i / arcSampleCount
|
||||
const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t
|
||||
const px = arc.center[0] + Math.cos(a) * arc.radius
|
||||
const py = arc.center[1] + Math.sin(a) * arc.radius
|
||||
points.push(`${px},${py}`)
|
||||
}
|
||||
|
||||
const aFontSize = Math.max(upx * 9, 0.075)
|
||||
const aPadX = upx * 5
|
||||
const aPadY = upx * 2.5
|
||||
const aTextWidth = arc.label.length * upx * 6.2
|
||||
const aPlateW = aTextWidth + aPadX * 2
|
||||
const aPlateH = aFontSize + aPadY * 2
|
||||
|
||||
const labelDist = arc.radius + upx * 16
|
||||
const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist
|
||||
const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist
|
||||
|
||||
return (
|
||||
<g key={`draft-angle-${arc.id}`}>
|
||||
<polyline
|
||||
fill="none"
|
||||
points={points.join(' ')}
|
||||
stroke={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={0.95}
|
||||
strokeWidth={upx * 1.2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<g transform={`translate(${lx} ${ly})`}>
|
||||
<rect
|
||||
fill={labelBg}
|
||||
height={aPlateH}
|
||||
opacity={0.92}
|
||||
rx={upx * 3}
|
||||
ry={upx * 3}
|
||||
stroke={stroke}
|
||||
strokeWidth={upx * 0.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
width={aPlateW}
|
||||
x={-aPlateW / 2}
|
||||
y={-aPlateH / 2}
|
||||
/>
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
fill={labelText}
|
||||
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||
fontSize={aFontSize}
|
||||
fontWeight={600}
|
||||
textAnchor="middle"
|
||||
x={0}
|
||||
y={0}
|
||||
>
|
||||
{arc.label}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
@@ -3875,6 +4046,7 @@ export function FloorplanPanel() {
|
||||
const viewportHostRef = useRef<HTMLDivElement>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const floorplanSceneRef = useRef<SVGGElement>(null)
|
||||
const floorplanContentRef = useRef<SVGGElement>(null)
|
||||
const panStateRef = useRef<PanState | null>(null)
|
||||
const guideInteractionRef = useRef<GuideInteractionState | null>(null)
|
||||
const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
|
||||
@@ -3904,6 +4076,11 @@ export function FloorplanPanel() {
|
||||
const selectedItem = useEditor((state) => state.selectedItem)
|
||||
|
||||
const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
|
||||
// Panel is permanently mounted and toggled via `display: none` in
|
||||
// editor/index.tsx — subscribing here lets us re-fit the viewport when
|
||||
// the user closes and re-opens the 2D editor instead of restoring the
|
||||
// stale viewport from before they closed it.
|
||||
const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
|
||||
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
|
||||
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
@@ -3943,6 +4120,30 @@ export function FloorplanPanel() {
|
||||
walls,
|
||||
zones,
|
||||
} = useFloorplanSceneData({ buildingId, levelId })
|
||||
// When only a building is selected (or we're mid-drag on a building),
|
||||
// the FloorplanRegistryLayer falls back to that building's level 0
|
||||
// (or lowest level) and renders it dimmed as context. We let the SVG
|
||||
// mount in that case so the dimmed-floor render path is reachable
|
||||
// instead of swapping in the "Switch to a building level" message.
|
||||
//
|
||||
// `currentBuildingId` covers both the user-selected building and the
|
||||
// building inferred from a selected level. During a building move the
|
||||
// `movingNode` carries the building's id even if the explicit
|
||||
// selection has been cleared as part of the move handoff.
|
||||
const movingBuildingId =
|
||||
useEditor((state) => {
|
||||
const moving = state.movingNode
|
||||
if (!moving) return null
|
||||
const def = nodeRegistry.get(moving.type)
|
||||
return def?.capabilities?.floorplanLevelContainer ? moving.id : null
|
||||
}) ?? null
|
||||
const ambientBuildingId = currentBuildingId ?? movingBuildingId
|
||||
const hasAmbientBuildingLevel = useScene((state) => {
|
||||
if (levelId || !ambientBuildingId) return false
|
||||
const building = state.nodes[ambientBuildingId]
|
||||
if (!building || building.type !== 'building') return false
|
||||
return building.children.some((cid) => state.nodes[cid]?.type === 'level')
|
||||
})
|
||||
const elevators = useScene(
|
||||
useShallow((state) => {
|
||||
const building = currentBuildingId ? state.nodes[currentBuildingId] : null
|
||||
@@ -4080,6 +4281,17 @@ export function FloorplanPanel() {
|
||||
const [isPanelReady, setIsPanelReady] = useState(false)
|
||||
const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 })
|
||||
const [viewport, setViewport] = useState<FloorplanViewport | null>(null)
|
||||
// Tight bbox of the painted floor-plan scene (the rotation `<g>`'s
|
||||
// children), read via SVG `getBBox()` after each render. The legacy
|
||||
// polygon arrays (`wallPolygons`, `displaySlabPolygons`, etc.) are now
|
||||
// empty stubs because rendering moved to the registry layer, so
|
||||
// measuring the DOM is how `fittedViewport` learns where content lives.
|
||||
const [measuredSceneBBox, setMeasuredSceneBBox] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (structureLayer === 'zones' && floorplanSelectionTool === 'marquee') {
|
||||
@@ -4820,7 +5032,7 @@ export function FloorplanPanel() {
|
||||
}, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon])
|
||||
|
||||
const draftPolygon = useMemo(() => {
|
||||
if (!(levelId && draftStart && draftEnd && isWallLongEnough(draftStart, draftEnd))) {
|
||||
if (!(levelId && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -4828,6 +5040,74 @@ export function FloorplanPanel() {
|
||||
// Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.
|
||||
return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
|
||||
}, [draftEnd, draftStart, levelId])
|
||||
// Live length + angle feedback for the wall draft — parity with the 3D
|
||||
// `WallTool` (`packages/nodes/src/wall/tool.tsx`), ported to 2D plan
|
||||
// space. Length renders at the segment midpoint; angle arcs sit at
|
||||
// each endpoint that meets an existing wall.
|
||||
const draftWallMeasurement = useMemo(() => {
|
||||
if (!(isWallBuildActive && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dx = draftEnd[0] - draftStart[0]
|
||||
const dy = draftEnd[1] - draftStart[1]
|
||||
const length = Math.hypot(dx, dy)
|
||||
|
||||
const draftFromStart: WallPlanPoint = [dx, dy]
|
||||
const draftFromEnd: WallPlanPoint = [-dx, -dy]
|
||||
const endpoints = [
|
||||
{ id: 'start', point: draftStart, draftVector: draftFromStart },
|
||||
{ id: 'end', point: draftEnd, draftVector: draftFromEnd },
|
||||
] as const
|
||||
|
||||
type AngleLabel = {
|
||||
id: string
|
||||
label: string
|
||||
center: WallPlanPoint
|
||||
radius: number
|
||||
startAngle: number
|
||||
endAngle: number
|
||||
midAngle: number
|
||||
}
|
||||
|
||||
const angleLabels: AngleLabel[] = []
|
||||
for (const endpoint of endpoints) {
|
||||
const connectedWall = walls.find((wall) =>
|
||||
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
|
||||
)
|
||||
if (!connectedWall) continue
|
||||
const ref = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
|
||||
if (!ref) continue
|
||||
|
||||
const angle = getAngleToSegmentReference(endpoint.draftVector, ref)
|
||||
if (angle === null) continue
|
||||
const arc = getAngleArcToSegmentReference(endpoint.draftVector, ref)
|
||||
if (!arc || arc.angle < 0.01) continue
|
||||
|
||||
const refLen = Math.hypot(ref.vector[0], ref.vector[1])
|
||||
const radius = Math.max(0.32, Math.min(0.72, Math.min(length, refLen) * 0.28))
|
||||
|
||||
angleLabels.push({
|
||||
id: endpoint.id,
|
||||
label: formatAngleRadians(angle),
|
||||
center: endpoint.point,
|
||||
radius,
|
||||
startAngle: arc.startAngle,
|
||||
endAngle: arc.endAngle,
|
||||
midAngle: arc.midAngle,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
lengthLabel: formatMeasurement(length, unit),
|
||||
midpoint: [
|
||||
(draftStart[0] + draftEnd[0]) / 2,
|
||||
(draftStart[1] + draftEnd[1]) / 2,
|
||||
] as WallPlanPoint,
|
||||
direction: [dx / length, dy / length] as WallPlanPoint,
|
||||
angleLabels,
|
||||
}
|
||||
}, [draftEnd, draftStart, isWallBuildActive, unit, walls])
|
||||
const draftPolygonPoints = useMemo(() => {
|
||||
if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
|
||||
const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
|
||||
@@ -4920,7 +5200,11 @@ export function FloorplanPanel() {
|
||||
const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1
|
||||
|
||||
const fittedViewport = useMemo(() => {
|
||||
const allPoints = [
|
||||
// Collect bounds from the legacy polygon arrays first. Most are empty
|
||||
// stubs (rendering moved to the registry layer), but we still honor
|
||||
// anything that does emit points so the fit is correct during the
|
||||
// brief window before `measuredSceneBBox` is populated.
|
||||
const legacyPoints = [
|
||||
...(visibleSitePolygon ? visibleSitePolygon.polygon : []),
|
||||
...displayCeilingPolygons.flatMap((entry) => entry.polygon),
|
||||
...displaySlabPolygons.flatMap((entry) => entry.polygon),
|
||||
@@ -4935,27 +5219,46 @@ export function FloorplanPanel() {
|
||||
...wallPolygons.flatMap((entry) => entry.polygon),
|
||||
]
|
||||
|
||||
if (allPoints.length === 0) {
|
||||
return {
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
width: Math.max(FALLBACK_VIEW_SIZE, FALLBACK_VIEW_SIZE * svgAspectRatio),
|
||||
}
|
||||
}
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const point of allPoints) {
|
||||
const svgPoint = toSvgPoint(point)
|
||||
for (const point of legacyPoints) {
|
||||
const svgPoint = rotateSvgPoint(toSvgPoint(point), floorplanSceneRotationDeg)
|
||||
minX = Math.min(minX, svgPoint.x)
|
||||
maxX = Math.max(maxX, svgPoint.x)
|
||||
minY = Math.min(minY, svgPoint.y)
|
||||
maxY = Math.max(maxY, svgPoint.y)
|
||||
}
|
||||
|
||||
// Fold in the DOM-measured bbox of the registry-driven scene. `getBBox`
|
||||
// returns coords in the rotation group's pre-transform space, so we
|
||||
// rotate the four corners to land in viewBox coords before bbox'ing.
|
||||
if (measuredSceneBBox && measuredSceneBBox.width >= 0 && measuredSceneBBox.height >= 0) {
|
||||
const { x, y, width: w, height: h } = measuredSceneBBox
|
||||
const corners = [
|
||||
rotateSvgPoint({ x, y }, floorplanSceneRotationDeg),
|
||||
rotateSvgPoint({ x: x + w, y }, floorplanSceneRotationDeg),
|
||||
rotateSvgPoint({ x, y: y + h }, floorplanSceneRotationDeg),
|
||||
rotateSvgPoint({ x: x + w, y: y + h }, floorplanSceneRotationDeg),
|
||||
]
|
||||
for (const corner of corners) {
|
||||
minX = Math.min(minX, corner.x)
|
||||
maxX = Math.max(maxX, corner.x)
|
||||
minY = Math.min(minY, corner.y)
|
||||
maxY = Math.max(maxY, corner.y)
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
|
||||
return {
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
width: Math.max(FALLBACK_VIEW_SIZE, FALLBACK_VIEW_SIZE * svgAspectRatio),
|
||||
}
|
||||
}
|
||||
|
||||
const rawWidth = maxX - minX
|
||||
const rawHeight = maxY - minY
|
||||
const paddedWidth = rawWidth + FLOORPLAN_PADDING * 2
|
||||
@@ -4976,13 +5279,52 @@ export function FloorplanPanel() {
|
||||
floorplanFenceEntries,
|
||||
floorplanItemEntries,
|
||||
floorplanRoofEntries,
|
||||
floorplanSceneRotationDeg,
|
||||
floorplanStairEntries,
|
||||
measuredSceneBBox,
|
||||
svgAspectRatio,
|
||||
visibleSitePolygon,
|
||||
visibleZonePolygons,
|
||||
wallPolygons,
|
||||
])
|
||||
|
||||
// Measure the painted floor-plan scene after each render. `getBBox()`
|
||||
// gives us the tight bounds of whatever the registry layer emitted,
|
||||
// even for kinds whose legacy entry arrays are empty stubs. Bail out
|
||||
// when nothing has painted (empty group throws in some browsers).
|
||||
// We measure the content-only sub-group (not the full scene group) to
|
||||
// exclude the grid layer, whose extent tracks the viewBox and would
|
||||
// otherwise create a measure→fit→measure update loop.
|
||||
useLayoutEffect(() => {
|
||||
const el = floorplanContentRef.current
|
||||
if (!el) return
|
||||
let bbox: { x: number; y: number; width: number; height: number }
|
||||
try {
|
||||
const measured = el.getBBox()
|
||||
bbox = {
|
||||
x: measured.x,
|
||||
y: measured.y,
|
||||
width: measured.width,
|
||||
height: measured.height,
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (bbox.width <= 0 && bbox.height <= 0) return
|
||||
setMeasuredSceneBBox((prev) => {
|
||||
if (
|
||||
prev &&
|
||||
prev.x === bbox.x &&
|
||||
prev.y === bbox.y &&
|
||||
prev.width === bbox.width &&
|
||||
prev.height === bbox.height
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return bbox
|
||||
})
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const host = viewportHostRef.current
|
||||
if (!host) {
|
||||
@@ -5030,6 +5372,19 @@ export function FloorplanPanel() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reset to auto-fit each time the 2D editor re-opens. The panel stays
|
||||
// mounted across close/open (hidden via `display: none`), so without
|
||||
// this the user's last pan/zoom — and any stale `measuredSceneBBox`
|
||||
// captured before they closed it — would survive and the reopened
|
||||
// editor would show the same off-screen viewport instead of fitting
|
||||
// to the current scene.
|
||||
useEffect(() => {
|
||||
if (!isFloorplanOpen) return
|
||||
hasUserAdjustedViewportRef.current = false
|
||||
setViewport(null)
|
||||
setMeasuredSceneBBox(null)
|
||||
}, [isFloorplanOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const levelChanged = previousLevelIdRef.current !== (levelId ?? null)
|
||||
|
||||
@@ -5108,32 +5463,6 @@ export function FloorplanPanel() {
|
||||
() => Math.max(floorplanWorldUnitsPerPixel * 0.55, 0.0001),
|
||||
[floorplanWorldUnitsPerPixel],
|
||||
)
|
||||
const floorplanCursorAnchorPosition = useMemo(() => {
|
||||
if (
|
||||
cursorPoint &&
|
||||
surfaceSize.width > 0 &&
|
||||
surfaceSize.height > 0 &&
|
||||
viewBox.width > 0 &&
|
||||
viewBox.height > 0
|
||||
) {
|
||||
return projectSvgPointToSurface(
|
||||
rotateSvgPoint(toSvgPlanPoint(cursorPoint), floorplanSceneRotationDeg),
|
||||
viewBox,
|
||||
surfaceSize,
|
||||
)
|
||||
}
|
||||
|
||||
return floorplanCursorPosition
|
||||
}, [
|
||||
cursorPoint,
|
||||
floorplanCursorPosition,
|
||||
floorplanSceneRotationDeg,
|
||||
surfaceSize,
|
||||
surfaceSize.height,
|
||||
surfaceSize.width,
|
||||
viewBox,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
setHoveredGuideCorner(null)
|
||||
}, [])
|
||||
@@ -5864,10 +6193,16 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
const svgPoint = getSvgPointFromClientPoint(clientX, clientY)
|
||||
if (!svgPoint) {
|
||||
// `getSvgPointFromClientPoint` resolves to the rotation group's
|
||||
// local coords (pre-rotation). The viewBox lives in the outer
|
||||
// SVG space (post-rotation), so apply the scene rotation here
|
||||
// before using the point as a zoom anchor — otherwise a rotated
|
||||
// scene zooms around the wrong location instead of the cursor.
|
||||
const localPoint = getSvgPointFromClientPoint(clientX, clientY)
|
||||
if (!localPoint) {
|
||||
return
|
||||
}
|
||||
const svgPoint = rotateSvgPoint(localPoint, floorplanSceneRotationDeg)
|
||||
|
||||
const currentViewport = viewport ?? fittedViewport
|
||||
const currentViewBox = viewBox
|
||||
@@ -5889,6 +6224,7 @@ export function FloorplanPanel() {
|
||||
},
|
||||
[
|
||||
fittedViewport,
|
||||
floorplanSceneRotationDeg,
|
||||
getSvgPointFromClientPoint,
|
||||
maxViewportWidth,
|
||||
minViewportWidth,
|
||||
@@ -6347,12 +6683,14 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
// Wall endpoint move: grid snap only (no 45° angle snap from the
|
||||
// fixed corner — that's draft-only behaviour). Shift switches
|
||||
// to the fine grid step for precision.
|
||||
const snappedPoint = snapWallDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
start: dragState.fixedPoint,
|
||||
angleSnap: !shiftPressed,
|
||||
ignoreWallIds: [dragState.wallId],
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
|
||||
if (pointsEqual(dragState.currentPoint, snappedPoint)) {
|
||||
@@ -6524,7 +6862,7 @@ export function FloorplanPanel() {
|
||||
)
|
||||
})
|
||||
|
||||
if (commitUpdates.length > 0 && isWallLongEnough(nextDraft.start, nextDraft.end)) {
|
||||
if (commitUpdates.length > 0 && isSegmentLongEnough(nextDraft.start, nextDraft.end)) {
|
||||
useScene.getState().updateNodes(
|
||||
commitUpdates.map((update) => ({
|
||||
id: update.id as AnyNodeId,
|
||||
@@ -6927,12 +7265,14 @@ export function FloorplanPanel() {
|
||||
if (isFenceBuildActive) {
|
||||
emitFloorplanGridEvent('move', planPoint, event)
|
||||
|
||||
// Fence draft: grid snap only — orthogonal fences fall out of
|
||||
// a grid-aligned start. Shift switches to the fine grid step
|
||||
// for precision. Mirrors `wall/tool.tsx`.
|
||||
const snappedPoint = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
fences,
|
||||
start: fenceDraftStart ?? undefined,
|
||||
angleSnap: Boolean(fenceDraftStart) && !shiftPressed,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
|
||||
setCursorPoint((previousPoint) =>
|
||||
@@ -6973,19 +7313,14 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
// Wall build also needs to run before the catch-all — see the
|
||||
// wall branch in `handleBackgroundPlacementClick` for the same
|
||||
// restructuring. The wall branch lives further below in this
|
||||
// handler (`if (!isWallBuildActive) ... setDraftEnd(...)`); the
|
||||
// grid emit is inlined there.
|
||||
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
|
||||
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Opening placement (door / window build, plus door / window move)
|
||||
// must run before the registry catch-all: door & window are
|
||||
// registered kinds, so `isRegistryToolBuildActive` is true during
|
||||
// their build mode and the catch-all would otherwise emit
|
||||
// `grid:move` and return — never emitting the `wall:enter` /
|
||||
// `wall:move` events the door / window placement tools listen for.
|
||||
// Same reason `handleBackgroundPlacementClick` runs its opening
|
||||
// branch before its grid catch-all.
|
||||
if (isOpeningPlacementActive) {
|
||||
const closest = findClosestWallPoint(planPoint, walls, {
|
||||
canUseWall: (wall) => !isCurvedWall(wall),
|
||||
@@ -7020,6 +7355,19 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
// Registry-driven catch-all for kinds without bespoke 2D handling
|
||||
// (shelf, etc.). Must run AFTER the opening branch above (door /
|
||||
// window are also registered kinds, but need wall events — see
|
||||
// comment there). Wall build skips this so its own branch below
|
||||
// updates local `draftEnd` state alongside the registry tool.
|
||||
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
|
||||
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (isMarqueeSelectionToolActive) {
|
||||
setCursorPoint((previousPoint) => {
|
||||
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||
@@ -7035,11 +7383,13 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
// Wall draft: grid snap only — orthogonal walls follow naturally
|
||||
// from a grid-aligned start. Shift switches to the fine grid step
|
||||
// (0.05m) for precision.
|
||||
const snappedPoint = snapWallDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
start: draftStart ?? undefined,
|
||||
angleSnap: Boolean(draftStart) && !shiftPressed,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
|
||||
// Emit `grid:move` so the registry-driven wall tool's 3D preview
|
||||
@@ -7241,14 +7591,34 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isWallLongEnough(draftStart, point)) {
|
||||
if (!isSegmentLongEnough(draftStart, point)) {
|
||||
return
|
||||
}
|
||||
|
||||
createWallOnCurrentLevel(draftStart, point)
|
||||
clearDraft()
|
||||
// The 3D wall tool's `grid:click` listener
|
||||
// (`packages/nodes/src/wall/tool.tsx`) owns the wall-create
|
||||
// call. `emitFloorplanGridEvent('click', …)` in
|
||||
// `useFloorplanBackgroundPlacement` fires it synchronously
|
||||
// just before this callback runs, so by the time we get here
|
||||
// the wall already exists in the scene.
|
||||
//
|
||||
// We still attempt the create as a fallback in case the 3D
|
||||
// tool isn't mounted (unusual — both views are always
|
||||
// mounted today, but defensive). When the wall already
|
||||
// exists `createWallOnCurrentLevel` returns null via its
|
||||
// duplicate-detection branch; we treat that as "the 3D side
|
||||
// committed" and chain the draft state forward instead of
|
||||
// clearing it (the previous behaviour caused the 2nd-segment
|
||||
// draft to silently break after click 2).
|
||||
const createdWall = createWallOnCurrentLevel(draftStart, point)
|
||||
const nextStart: WallPlanPoint = createdWall
|
||||
? [createdWall.end[0], createdWall.end[1]]
|
||||
: point
|
||||
setDraftStart(nextStart)
|
||||
setDraftEnd(nextStart)
|
||||
setCursorPoint(nextStart)
|
||||
},
|
||||
[clearDraft, draftStart],
|
||||
[draftStart],
|
||||
)
|
||||
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
|
||||
ceilingPolygons: displayCeilingPolygons,
|
||||
@@ -8224,7 +8594,6 @@ export function FloorplanPanel() {
|
||||
<FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} />
|
||||
<div className="relative min-h-0 flex-1" ref={viewportHostRef}>
|
||||
<Editor2dFloorplanCursorIndicatorOverlay
|
||||
cursorAnchorPosition={floorplanCursorAnchorPosition}
|
||||
cursorColor={floorplanCursorColor}
|
||||
cursorPosition={floorplanCursorPosition}
|
||||
floorplanSelectionTool={floorplanSelectionTool}
|
||||
@@ -8357,7 +8726,7 @@ export function FloorplanPanel() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!levelNode || levelNode.type !== 'level' ? (
|
||||
{(!levelNode || levelNode.type !== 'level') && !hasAmbientBuildingLevel ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
|
||||
Switch to a building level to view and edit the floorplan.
|
||||
</div>
|
||||
@@ -8542,9 +8911,23 @@ export function FloorplanPanel() {
|
||||
<FloorplanRenderProvider
|
||||
hatchPatternId={wallSelectionHatchId}
|
||||
palette={floorplanRegistryPalette}
|
||||
sceneRotationDeg={floorplanSceneRotationDeg}
|
||||
unitsPerPixel={floorplanUnitsPerPixel}
|
||||
>
|
||||
{/* Wrapped in a measured `<g>` so `fittedViewport` can
|
||||
fit to just the painted node geometry — measuring the
|
||||
whole rotation group would include the grid layer,
|
||||
whose extent is derived from the current viewBox and
|
||||
would create a measure→fit→measure loop. */}
|
||||
<g ref={floorplanContentRef}>
|
||||
<FloorplanRegistryLayer />
|
||||
{/* Bridge-wall ghost previews painted on top of the
|
||||
registry layer (drag-time only); cleared by the
|
||||
wall move's `commit()` so real bridges replace
|
||||
them without a frame of overlap. See
|
||||
`floorplan-wall-move-ghost-layer.tsx`. */}
|
||||
<FloorplanWallMoveGhostLayer />
|
||||
</g>
|
||||
</FloorplanRenderProvider>
|
||||
{/* Cursor-driven placement ghost for movingNode when the
|
||||
active kind is registry-driven. Renders via a portal
|
||||
@@ -8595,6 +8978,17 @@ export function FloorplanPanel() {
|
||||
unitsPerPixel={floorplanUnitsPerPixel}
|
||||
/>
|
||||
|
||||
{draftWallMeasurement && (
|
||||
<FloorplanDraftWallMeasurement
|
||||
labelBackground={isDark ? '#0f172a' : '#ffffff'}
|
||||
labelText={isDark ? '#e2e8f0' : '#171717'}
|
||||
measurement={draftWallMeasurement}
|
||||
measurementStroke={palette.measurementStroke}
|
||||
sceneRotationDeg={floorplanSceneRotationDeg}
|
||||
unitsPerPixel={floorplanUnitsPerPixel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Wall / fence endpoint, wall curve, slab / ceiling /
|
||||
zone vertex+midpoint+edge handles are all driven by the
|
||||
registry's `def.floorplanAffordances` and rendered as
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||
import { type AnyNodeId, emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MathUtils, type Mesh, Vector2 } from 'three'
|
||||
import { MathUtils, type Mesh, PlaneGeometry, Vector2, Vector3 } from 'three'
|
||||
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useGridEvents } from '../../hooks/use-grid-events'
|
||||
@@ -118,10 +118,18 @@ export const Grid = ({
|
||||
// Use custom raycasting for grid events (independent of mesh events)
|
||||
useGridEvents(gridY)
|
||||
|
||||
// Update cursor position from grid:move events
|
||||
// Track the last world-space cursor hit. The reveal-fade shader reads
|
||||
// `positionLocal.xy` (vertex position on the un-transformed plane), and
|
||||
// the mesh's -π/2 X rotation maps `positionLocal.y` to world `-Z`
|
||||
// relative to the mesh origin. The mesh origin itself is lerped each
|
||||
// frame toward the active building's world XZ (see `useFrame` below),
|
||||
// so the local-frame cursor must be recomputed every frame from the
|
||||
// stored world cursor — otherwise the ring drifts whenever the grid is
|
||||
// mid-lerp (e.g. just after a building rotation commits).
|
||||
const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null)
|
||||
useEffect(() => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
cursorPositionRef.current.set(event.position[0], -event.position[2])
|
||||
lastWorldCursorRef.current = { x: event.position[0], z: event.position[2] }
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
@@ -130,31 +138,71 @@ export const Grid = ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const worldPosScratch = useMemo(() => new Vector3(), [])
|
||||
useFrame((_, delta) => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
const { levelId, buildingId } = useViewer.getState().selection
|
||||
// Align the grid's XZ origin to the active building so its visible cell
|
||||
// lines pass through building-local snap points (walls snap in
|
||||
// building-local coords; a building placed at world (0.25, 0.25) would
|
||||
// otherwise leave snapped wall endpoints stranded between grid lines).
|
||||
let targetX = 0
|
||||
let targetZ = 0
|
||||
if (buildingId) {
|
||||
const buildingMesh = sceneRegistry.nodes.get(buildingId as AnyNodeId)
|
||||
if (buildingMesh) {
|
||||
buildingMesh.getWorldPosition(worldPosScratch)
|
||||
targetX = worldPosScratch.x
|
||||
targetZ = worldPosScratch.z
|
||||
}
|
||||
}
|
||||
let targetY = 0
|
||||
if (currentLevelId) {
|
||||
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
||||
if (levelId) {
|
||||
const levelMesh = sceneRegistry.nodes.get(levelId)
|
||||
if (levelMesh) {
|
||||
targetY = levelMesh.position.y
|
||||
}
|
||||
}
|
||||
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
|
||||
const t = 12 * delta
|
||||
gridRef.current.position.x = MathUtils.lerp(gridRef.current.position.x, targetX, t)
|
||||
gridRef.current.position.z = MathUtils.lerp(gridRef.current.position.z, targetZ, t)
|
||||
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, t)
|
||||
gridRef.current.position.y = newY
|
||||
setGridY(newY)
|
||||
|
||||
// Re-derive the local-frame cursor uniform after the grid's XZ has
|
||||
// lerped this frame, so the reveal ring stays locked under the world
|
||||
// cursor even when the grid origin is mid-transition.
|
||||
const world = lastWorldCursorRef.current
|
||||
if (world) {
|
||||
cursorPositionRef.current.set(
|
||||
world.x - gridRef.current.position.x,
|
||||
-(world.z - gridRef.current.position.z),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
|
||||
// Pass the geometry as a prop instead of a JSX child so the mesh
|
||||
// is never reconciled with R3F's empty placeholder `BufferGeometry`.
|
||||
// Combined with the grid's `MeshBasicNodeMaterial`, the child-attach
|
||||
// path can submit a `Draw(0, 1, 0, 0)` on the first frame before
|
||||
// `<planeGeometry>` attaches — which WebGPU flags as "Vertex buffer
|
||||
// slot 0 ... was not set" (see `wall-move-side-handles.tsx`).
|
||||
const geometry = useMemo(
|
||||
() => new PlaneGeometry(fadeDistance * 2, fadeDistance * 2),
|
||||
[fadeDistance],
|
||||
)
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
return (
|
||||
<mesh
|
||||
geometry={geometry}
|
||||
layers={GRID_LAYER}
|
||||
material={material}
|
||||
ref={gridRef}
|
||||
rotation-x={-Math.PI / 2}
|
||||
visible={showGrid}
|
||||
>
|
||||
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
|
||||
</mesh>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import { FloatingBuildingActionMenu } from './floating-building-action-menu'
|
||||
import { FloorplanPanel } from './floorplan-panel'
|
||||
import { Grid } from './grid'
|
||||
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
|
||||
import { NodeArrowHandles } from './node-arrow-handles'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
|
||||
@@ -571,6 +572,16 @@ function PaintCursorBadge({
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribes to `gridSnapStep` so the visible grid cell size matches whatever
|
||||
// the wall draft tool snaps to — otherwise the cursor lands between visible
|
||||
// grid lines when the user picks a finer snap (0.25 / 0.1 / 0.05).
|
||||
function SnapAwareGrid() {
|
||||
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
||||
return (
|
||||
<Grid cellColor="#aaa" cellSize={gridSnapStep} fadeDistance={500} sectionColor="#ccc" />
|
||||
)
|
||||
}
|
||||
|
||||
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
|
||||
|
||||
const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
@@ -588,6 +599,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
<>
|
||||
{!isFirstPersonMode && <SelectionManager />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <NodeArrowHandles />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
|
||||
@@ -598,9 +610,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
<CeilingSelectionAffordanceSystem />
|
||||
<RoofEditSystem />
|
||||
<StairEditSystem />
|
||||
{!(isLoading || isFirstPersonMode) && (
|
||||
<Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
|
||||
)}
|
||||
{!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
|
||||
{!(isLoading || isVersionPreviewMode || isFirstPersonMode) && <ToolManager />}
|
||||
{isFirstPersonMode && <FirstPersonControls />}
|
||||
<CustomCameraControls />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-ap
|
||||
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
|
||||
import { getPlanPointDistance } from '../../lib/floorplan'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||
import { WALL_FINE_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||
|
||||
type UseFloorplanBackgroundPlacementArgs = {
|
||||
activePolygonDraftPoints: WallPlanPoint[]
|
||||
@@ -53,7 +53,8 @@ type UseFloorplanBackgroundPlacementArgs = {
|
||||
point: WallPlanPoint
|
||||
walls: WallNode[]
|
||||
start?: WallPlanPoint
|
||||
angleSnap: boolean
|
||||
angleSnap?: boolean
|
||||
step?: number
|
||||
}) => WallPlanPoint
|
||||
snapPolygonDraftPoint: (args: {
|
||||
point: WallPlanPoint
|
||||
@@ -156,12 +157,12 @@ export function useFloorplanBackgroundPlacement({
|
||||
if (isFenceBuildActive) {
|
||||
emitFloorplanGridEvent('click', planPoint, event)
|
||||
|
||||
// Fence draft: grid snap only; Shift = fine step. See `wall/tool.tsx`.
|
||||
const snappedPoint = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
fences,
|
||||
start: fenceDraftStart ?? undefined,
|
||||
angleSnap: Boolean(fenceDraftStart) && !shiftPressed,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
|
||||
setCursorPoint(snappedPoint)
|
||||
@@ -212,11 +213,11 @@ export function useFloorplanBackgroundPlacement({
|
||||
// / draftEnd state in the floor plan would never update, leaving
|
||||
// the dashed-line draft preview invisible.
|
||||
if (isWallBuildActive) {
|
||||
// Wall draft: grid snap only; Shift = fine step. See `wall/tool.tsx`.
|
||||
const snappedPoint = snapWallDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
start: draftStart ?? undefined,
|
||||
angleSnap: Boolean(draftStart) && !shiftPressed,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type SiteNode,
|
||||
type SlabNode,
|
||||
type SpawnNode,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
@@ -59,13 +60,22 @@ export function useFloorplanSceneData({
|
||||
? (levelNode.parentId as BuildingNode['id'])
|
||||
: buildingId
|
||||
|
||||
const buildingRotationY = useScene((state) => {
|
||||
// Live transform override — when the building is mid-drag (the move
|
||||
// tool publishes per-frame pose to useLiveTransforms), the floor-plan
|
||||
// follows that pose so the dimmed reference floor tracks the cursor
|
||||
// instead of snapping only on commit.
|
||||
const buildingLiveTransform = useLiveTransforms((state) =>
|
||||
currentBuildingId ? state.transforms.get(currentBuildingId) : undefined,
|
||||
)
|
||||
|
||||
const committedBuildingRotationY = useScene((state) => {
|
||||
if (!currentBuildingId) return 0
|
||||
const node = state.nodes[currentBuildingId]
|
||||
return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
|
||||
})
|
||||
const buildingRotationY = buildingLiveTransform?.rotation ?? committedBuildingRotationY
|
||||
|
||||
const buildingPosition = useScene((state) => {
|
||||
const committedBuildingPosition = useScene((state) => {
|
||||
if (!currentBuildingId) {
|
||||
return DEFAULT_BUILDING_POSITION
|
||||
}
|
||||
@@ -75,6 +85,7 @@ export function useFloorplanSceneData({
|
||||
? (node.position as [number, number, number])
|
||||
: DEFAULT_BUILDING_POSITION
|
||||
})
|
||||
const buildingPosition = buildingLiveTransform?.position ?? committedBuildingPosition
|
||||
|
||||
const site = useScene((state) => {
|
||||
for (const rootNodeId of state.rootNodeIds) {
|
||||
|
||||
@@ -32,6 +32,12 @@ const HEIGHT_TICK_HALF_LENGTH = 0.14
|
||||
const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16
|
||||
|
||||
const BAR_AXIS = new THREE.Vector3(0, 1, 0)
|
||||
// Shared unit cube — each MeasurementBar scales it to BAR_THICKNESS × length
|
||||
// × BAR_THICKNESS instead of constructing a fresh BoxGeometry every frame.
|
||||
// Per-frame `<boxGeometry args={[..., length, ...]}/>` triggers R3F to
|
||||
// rebuild the geometry whenever the wall moves, and the WebGPU backend
|
||||
// flags the in-flight buffer churn as "Vertex buffer slot N ... was not set".
|
||||
const SHARED_BAR_GEOMETRY = new THREE.BoxGeometry(1, 1, 1)
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
|
||||
@@ -73,8 +79,7 @@ export function WallMeasurementLabel() {
|
||||
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||
const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
|
||||
const measurableNode =
|
||||
selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
|
||||
const measurableNode = selectedNode?.type === 'item' ? selectedNode : null
|
||||
|
||||
const [objectState, setObjectState] = useState<{
|
||||
id: AnyNodeId
|
||||
@@ -447,11 +452,12 @@ function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color:
|
||||
|
||||
return (
|
||||
<mesh
|
||||
geometry={SHARED_BAR_GEOMETRY}
|
||||
position={[segment.position.x, segment.position.y, segment.position.z]}
|
||||
quaternion={segment.quaternion}
|
||||
renderOrder={1000}
|
||||
scale={[BAR_THICKNESS, segment.length, BAR_THICKNESS]}
|
||||
>
|
||||
<boxGeometry args={[BAR_THICKNESS, segment.length, BAR_THICKNESS]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
|
||||
@@ -8,31 +8,49 @@ import {
|
||||
getWallThickness,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
ConeGeometry,
|
||||
Color,
|
||||
CylinderGeometry,
|
||||
DoubleSide,
|
||||
Float32BufferAttribute,
|
||||
ExtrudeGeometry,
|
||||
type Group,
|
||||
type Object3D,
|
||||
OrthographicCamera,
|
||||
Plane,
|
||||
Quaternion,
|
||||
Shape,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
|
||||
const HANDLE_OFFSET = 0.42
|
||||
const HANDLE_MIN_OFFSET = 0.5
|
||||
const HANDLE_MIN_HEIGHT = 0.62
|
||||
const HANDLE_OFFSET = 0.27
|
||||
const HANDLE_MIN_OFFSET = 0.33
|
||||
const HANDLE_MIN_HEIGHT = 0.4
|
||||
const HANDLE_TOP_INSET = 0.08
|
||||
const HEIGHT_HANDLE_OFFSET = 0.26
|
||||
const MIN_WALL_HEIGHT = 0.5
|
||||
const ARROW_COLOR = '#8381ed'
|
||||
const ARROW_HOVER_COLOR = '#a5b4fc'
|
||||
// Match the door arrows: scale the rendered chevron down to ~two-thirds
|
||||
// so the in-world handles read as a single UI family.
|
||||
const ARROW_SCALE = 0.65
|
||||
const CORNER_HEX_RADIUS = 0.16
|
||||
const CORNER_DASH_SIZE = 0.1
|
||||
const CORNER_GAP_SIZE = 0.07
|
||||
const CORNER_DASH_THICKNESS = 0.006
|
||||
const CORNER_FLOOR_OFFSET = 0.01
|
||||
|
||||
type WallMoveHandle = {
|
||||
key: string
|
||||
@@ -40,41 +58,49 @@ type WallMoveHandle = {
|
||||
rotationY: number
|
||||
}
|
||||
|
||||
// Pre-empt the synthetic `click` the browser fires immediately after a
|
||||
// drag's pointerup. Without this, PointerMissedHandler treats the click
|
||||
// as "missed" and deselects the wall when the height arrow drag commits.
|
||||
function swallowNextClick() {
|
||||
const swallow = (clickEvent: Event) => {
|
||||
clickEvent.stopPropagation()
|
||||
clickEvent.preventDefault()
|
||||
}
|
||||
window.addEventListener('click', swallow, { capture: true, once: true })
|
||||
setTimeout(() => {
|
||||
window.removeEventListener('click', swallow, { capture: true })
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function createArrowHandleGeometry() {
|
||||
const shaft = new CylinderGeometry(0.04, 0.064, 0.25, 36)
|
||||
const head = new ConeGeometry(0.13, 0.3, 48)
|
||||
shaft.rotateZ(-Math.PI / 2)
|
||||
shaft.translate(-0.085, 0, 0)
|
||||
head.rotateZ(-Math.PI / 2)
|
||||
head.translate(0.17, 0, 0)
|
||||
// Classic arrow silhouette — chevron head + rectangular shaft — extruded
|
||||
// slightly so the handle reads as a 3D plate but stays visually light.
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0.22, 0)
|
||||
shape.lineTo(-0.04, 0.12)
|
||||
shape.lineTo(-0.04, 0.035)
|
||||
shape.lineTo(-0.2, 0.035)
|
||||
shape.lineTo(-0.2, -0.035)
|
||||
shape.lineTo(-0.04, -0.035)
|
||||
shape.lineTo(-0.04, -0.12)
|
||||
shape.lineTo(0.22, 0)
|
||||
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
const geometry = new ExtrudeGeometry(shape, {
|
||||
depth: 0.08,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: 0.035,
|
||||
bevelSize: 0.03,
|
||||
bevelOffset: 0,
|
||||
bevelSegments: 10,
|
||||
curveSegments: 16,
|
||||
steps: 1,
|
||||
})
|
||||
|
||||
for (const sourceGeometry of [shaft, head]) {
|
||||
const geometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry
|
||||
const position = geometry.getAttribute('position')
|
||||
const normal = geometry.getAttribute('normal')
|
||||
const uv = geometry.getAttribute('uv')
|
||||
|
||||
for (let index = 0; index < position.count; index += 1) {
|
||||
positions.push(position.getX(index), position.getY(index), position.getZ(index))
|
||||
normals.push(normal.getX(index), normal.getY(index), normal.getZ(index))
|
||||
uvs.push(uv?.getX(index) ?? 0, uv?.getY(index) ?? 0)
|
||||
}
|
||||
|
||||
if (geometry !== sourceGeometry) {
|
||||
geometry.dispose()
|
||||
}
|
||||
sourceGeometry.dispose()
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
|
||||
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
|
||||
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
|
||||
// Centre the extruded plate around y=0 and re-orient it so the depth
|
||||
// axis points up: the chevron lies flat in the XZ plane, tip along +X,
|
||||
// wings spread across ±Z.
|
||||
geometry.translate(0, 0, -0.04)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
@@ -91,9 +117,14 @@ export function WallMoveSideHandles() {
|
||||
const curvingFence = useEditor((state) => state.curvingFence)
|
||||
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||
// Fence side-move / height / corner-pickers now flow through the
|
||||
// registry handle path (see packages/nodes/src/fence/definition.ts).
|
||||
// Only walls still need the legacy renderer here — the registry path
|
||||
// didn't render correctly for walls specifically and was reverted in
|
||||
// commit 0e207a7f; revisit once that's diagnosed.
|
||||
const selectedNode = useScene((state) => {
|
||||
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
|
||||
return node?.type === 'wall' || node?.type === 'fence' ? node : null
|
||||
return node?.type === 'wall' ? node : null
|
||||
})
|
||||
|
||||
const shouldRender =
|
||||
@@ -108,11 +139,7 @@ export function WallMoveSideHandles() {
|
||||
|
||||
if (!shouldRender || !selectedNode) return null
|
||||
|
||||
return selectedNode.type === 'wall' ? (
|
||||
<WallMoveSideHandlesForWall wall={selectedNode} />
|
||||
) : (
|
||||
<WallMoveSideHandlesForFence fence={selectedNode} />
|
||||
)
|
||||
return <WallMoveSideHandlesForWall wall={selectedNode} />
|
||||
}
|
||||
|
||||
function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
|
||||
@@ -157,19 +184,368 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
|
||||
{handles.map((handle) => (
|
||||
<WallMoveArrowHandle handle={handle} key={handle.key} wall={wall} />
|
||||
))}
|
||||
<WallHeightArrowHandle wall={wall} />
|
||||
<WallCornerLeaderHandle endpoint="start" wall={wall} />
|
||||
<WallCornerLeaderHandle endpoint="end" wall={wall} />
|
||||
</group>,
|
||||
levelObject,
|
||||
)
|
||||
}
|
||||
|
||||
function buildDashedVerticalGeometry(height: number) {
|
||||
// Build each dash as a thin cylinder section so thickness is
|
||||
// controllable — native `lineSegments` lock to 1px on WebGL/WebGPU.
|
||||
const dashes: BufferGeometry[] = []
|
||||
let y = 0
|
||||
while (y < height) {
|
||||
const end = Math.min(y + CORNER_DASH_SIZE, height)
|
||||
const length = end - y
|
||||
const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8)
|
||||
cylinder.translate(0, y + length / 2, 0)
|
||||
dashes.push(cylinder)
|
||||
y = end + CORNER_GAP_SIZE
|
||||
}
|
||||
const merged = mergeGeometries(dashes, false) ?? new BufferGeometry()
|
||||
for (const dash of dashes) dash.dispose()
|
||||
return merged
|
||||
}
|
||||
|
||||
function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: 'start' | 'end' }) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const { camera } = useThree()
|
||||
const billboardRef = useRef<Group>(null)
|
||||
const parentWorldQuaternionRef = useRef(new Quaternion())
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const scale = (isHovered ? 1.25 : 1) * zoom
|
||||
|
||||
const corner = endpoint === 'start' ? wall.start : wall.end
|
||||
const x = corner[0]
|
||||
const z = corner[1]
|
||||
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
|
||||
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
|
||||
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
|
||||
|
||||
// Node materials matched to the rest of the file — mixing plain
|
||||
// `meshBasicMaterial` with WebGPU node materials trips
|
||||
// "Color target has no corresponding fragment stage output".
|
||||
const dashMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: new Color(ARROW_COLOR),
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const hexMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: new Color(ARROW_COLOR),
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.95,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const ringMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: new Color(ARROW_COLOR),
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR
|
||||
dashMaterial.color.set(next)
|
||||
hexMaterial.color.set(next)
|
||||
ringMaterial.color.set(next)
|
||||
}, [dashMaterial, hexMaterial, ringMaterial, isHovered])
|
||||
|
||||
useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
|
||||
useEffect(() => () => hexMaterial.dispose(), [hexMaterial])
|
||||
useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
|
||||
|
||||
// Billboard the hex disc to the camera so the picker is always
|
||||
// recognisable regardless of viewing angle.
|
||||
//
|
||||
// Why parent-aware: the disc lives under a `createPortal` into the
|
||||
// level object, which itself sits under a building. Both can have
|
||||
// non-identity world rotations. `quaternion.copy(camera.quaternion)`
|
||||
// alone sets the LOCAL quaternion, so any ancestor rotation rotates
|
||||
// the disc away from the camera. We instead solve for a local
|
||||
// quaternion whose composition with the parent world quaternion
|
||||
// equals the camera's: `local = parentWorld⁻¹ · cameraWorld`.
|
||||
useFrame(() => {
|
||||
const billboard = billboardRef.current
|
||||
if (!billboard) return
|
||||
billboard.quaternion.copy(camera.quaternion)
|
||||
const parent = billboard.parent
|
||||
if (parent) {
|
||||
parent.getWorldQuaternion(parentWorldQuaternionRef.current)
|
||||
billboard.quaternion.premultiply(parentWorldQuaternionRef.current.invert())
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
document.body.style.cursor = 'grabbing'
|
||||
useEditor.getState().setMovingWallEndpoint({ wall, endpoint })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={dashedGeometry}
|
||||
material={dashMaterial}
|
||||
position={[x, 0, z]}
|
||||
renderOrder={1001}
|
||||
/>
|
||||
<group position={[x, CORNER_FLOOR_OFFSET, z]} ref={billboardRef} scale={scale}>
|
||||
<mesh
|
||||
material={hexMaterial}
|
||||
onPointerDown={activateEndpointMove}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
setIsHovered(true)
|
||||
document.body.style.cursor = 'grab'
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
event.stopPropagation()
|
||||
setIsHovered(false)
|
||||
if (document.body.style.cursor === 'grab') {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}}
|
||||
renderOrder={1003}
|
||||
>
|
||||
<circleGeometry args={[CORNER_HEX_RADIUS, 6]} />
|
||||
</mesh>
|
||||
<mesh material={ringMaterial} renderOrder={1002}>
|
||||
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
|
||||
</mesh>
|
||||
</group>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
||||
const arrowMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: new Color(ARROW_COLOR),
|
||||
side: DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const { camera, raycaster, gl } = useThree()
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
|
||||
const dragCleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
||||
}, [arrowMaterial, isHovered])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (document.body.style.cursor === 'ns-resize') {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
dragCleanupRef.current?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||
|
||||
// Sit on the visual centre of the wall — for curved walls that's the
|
||||
// arc apex at t=0.5, not the chord midpoint. Use the curve tangent for
|
||||
// the yaw so the arrow's local frame matches the wall direction at the
|
||||
// apex, consistent with `getWallMoveHandles`.
|
||||
const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null
|
||||
const midX = curveFrame ? curveFrame.point.x : (wall.start[0] + wall.end[0]) / 2
|
||||
const midZ = curveFrame ? curveFrame.point.y : (wall.start[1] + wall.end[1]) / 2
|
||||
const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0]
|
||||
const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1]
|
||||
const wallAngle = Math.atan2(-dirZ, dirX)
|
||||
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
|
||||
|
||||
const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
|
||||
if (!levelObject) return
|
||||
|
||||
// Vertical plane through the wall midpoint whose normal points toward
|
||||
// the camera (projected to horizontal). Raycasting against it converts
|
||||
// pointer movement into a world-space Y value.
|
||||
const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld)
|
||||
const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0)
|
||||
if (planeNormal.lengthSq() === 0) return
|
||||
planeNormal.normalize()
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld)
|
||||
|
||||
const ndc = new Vector2()
|
||||
const setNDC = (clientX: number, clientY: number) => {
|
||||
const rect = gl.domElement.getBoundingClientRect()
|
||||
ndc.set(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
}
|
||||
|
||||
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
const hit = new Vector3()
|
||||
if (!raycaster.ray.intersectPlane(plane, hit)) return
|
||||
|
||||
const initialHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
const initialY = hit.y
|
||||
const wallId = wall.id as AnyNodeId
|
||||
let pendingHeight = initialHeight
|
||||
|
||||
document.body.style.cursor = 'ns-resize'
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useEditor.getState().setActiveHandleDrag({ nodeId: wallId, label: 'height' })
|
||||
// Suppress R3F node pointer events until pointerup completes so the
|
||||
// synthesized click doesn't reroute selection to whatever mesh sits
|
||||
// under the cursor at release.
|
||||
useViewer.getState().setInputDragging(true)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
// Drag publishes `{ height }` to `useLiveNodeOverrides` and marks
|
||||
// the wall dirty so `WallSystem.updateWallGeometry` rebuilds against
|
||||
// the override-merged value (via `getEffectiveWall`). Zustand stays
|
||||
// at the pre-drag height until pointerup commits one tracked write.
|
||||
const onMove = (e: PointerEvent) => {
|
||||
setNDC(e.clientX, e.clientY)
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
const intersection = new Vector3()
|
||||
if (!raycaster.ray.intersectPlane(plane, intersection)) return
|
||||
const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY))
|
||||
pendingHeight = newHeight
|
||||
useLiveNodeOverrides.getState().set(wallId, { height: newHeight })
|
||||
useScene.getState().markDirty(wallId)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onCancel)
|
||||
if (document.body.style.cursor === 'ns-resize') {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
useEditor.getState().setActiveHandleDrag(null)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
dragCleanupRef.current = null
|
||||
}
|
||||
const onUp = () => {
|
||||
swallowNextClick()
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
// Commit: write the final override-merged value to zustand once
|
||||
// (tracked, undoable), then drop the override so the renderer
|
||||
// falls back to the scene store.
|
||||
if (pendingHeight !== initialHeight) {
|
||||
useScene.getState().updateNode(wallId, { height: pendingHeight })
|
||||
}
|
||||
useLiveNodeOverrides.getState().clear(wallId)
|
||||
useScene.getState().markDirty(wallId)
|
||||
cleanup()
|
||||
}
|
||||
const onCancel = () => {
|
||||
// Revert: drop the override, mark dirty so the geometry rebuilds
|
||||
// against the original scene height.
|
||||
useLiveNodeOverrides.getState().clear(wallId)
|
||||
useScene.getState().markDirty(wallId)
|
||||
cleanup()
|
||||
}
|
||||
|
||||
dragCleanupRef.current = cleanup
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onCancel)
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={[midX, handleY, midZ]} rotation={[0, wallAngle, 0]}>
|
||||
<group rotation={[0, Math.PI / 2, Math.PI / 2]} scale={scale}>
|
||||
<mesh
|
||||
// Geometry-as-prop + frustumCulled={false} — see WallMoveArrowHandle.
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
onPointerDown={activateHeightResize}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
setIsHovered(true)
|
||||
document.body.style.cursor = 'ns-resize'
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
event.stopPropagation()
|
||||
setIsHovered(false)
|
||||
if (document.body.style.cursor === 'ns-resize') {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}}
|
||||
renderOrder={1002}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
||||
const arrowMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: new Color(ARROW_COLOR),
|
||||
side: DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const { camera } = useThree()
|
||||
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
|
||||
const scale = (isHovered ? 1.12 : 1) * zoom
|
||||
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
|
||||
|
||||
useEffect(() => {
|
||||
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
||||
}, [arrowMaterial, isHovered])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -180,10 +556,10 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||
|
||||
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.preventDefault()
|
||||
document.body.style.cursor = 'grabbing'
|
||||
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
@@ -192,14 +568,21 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
||||
useEditor.getState().setMovingFenceEndpoint(null)
|
||||
useEditor.getState().setCurvingWall(null)
|
||||
useEditor.getState().setCurvingFence(null)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
// Keep the wall selected so it stays the active item once the move
|
||||
// commits; the `!movingNode` guard on the handles hides them mid-drag.
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
|
||||
<mesh
|
||||
// Pass geometry as a prop (not `<primitive attach="geometry">`)
|
||||
// so the mesh is never rendered with R3F's default empty
|
||||
// `BufferGeometry`. Combined with `frustumCulled={false}`, the
|
||||
// primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first
|
||||
// frame and WebGPU flags "Vertex buffer slot 0 ... was not set".
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
onPointerDown={activateWallMove}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -214,17 +597,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
||||
}
|
||||
}}
|
||||
renderOrder={1002}
|
||||
>
|
||||
<primitive attach="geometry" object={arrowGeometry} />
|
||||
<meshBasicMaterial
|
||||
color={isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR}
|
||||
depthTest
|
||||
depthWrite
|
||||
opacity={1}
|
||||
side={DoubleSide}
|
||||
transparent={false}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -232,10 +605,26 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
||||
function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
||||
const arrowMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: new Color(ARROW_COLOR),
|
||||
side: DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const { camera } = useThree()
|
||||
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const scale = (isHovered ? 1.12 : 1) * zoom
|
||||
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
|
||||
|
||||
useEffect(() => {
|
||||
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
||||
}, [arrowMaterial, isHovered])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -246,10 +635,10 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||
|
||||
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.preventDefault()
|
||||
document.body.style.cursor = 'grabbing'
|
||||
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
@@ -258,14 +647,18 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
||||
useEditor.getState().setMovingFenceEndpoint(null)
|
||||
useEditor.getState().setCurvingWall(null)
|
||||
useEditor.getState().setCurvingFence(null)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
// Keep the fence selected so it stays active once the move commits.
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
|
||||
<mesh
|
||||
// Pass geometry as a prop — see WallMoveArrowHandle for the
|
||||
// WebGPU "Vertex buffer slot 0 ... was not set" rationale.
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
|
||||
onPointerDown={activateFenceMove}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -280,17 +673,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
||||
}
|
||||
}}
|
||||
renderOrder={1002}
|
||||
>
|
||||
<primitive attach="geometry" object={arrowGeometry} />
|
||||
<meshBasicMaterial
|
||||
color={isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR}
|
||||
depthTest
|
||||
depthWrite
|
||||
opacity={1}
|
||||
side={DoubleSide}
|
||||
transparent={false}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
emitter,
|
||||
type GridEvent,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
@@ -15,6 +16,8 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const Y_AXIS = new THREE.Vector3(0, 1, 0)
|
||||
|
||||
export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
|
||||
@@ -28,9 +31,27 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
const originalRotationRef = useRef<number>(node.rotation[1] ?? 0)
|
||||
const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0)
|
||||
|
||||
// Local-space offset from the building's origin to its bbox center. The
|
||||
// floating drag button anchors at the bbox center, so we pin that point to
|
||||
// the cursor during the drag — otherwise the raw origin (often nowhere near
|
||||
// the visual center) would snap to the cursor and the building would jump.
|
||||
const centerOffsetLocalRef = useRef<THREE.Vector3>(new THREE.Vector3())
|
||||
|
||||
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) {
|
||||
const box = new THREE.Box3().setFromObject(obj)
|
||||
if (!box.isEmpty()) {
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
const originWorld = new THREE.Vector3()
|
||||
obj.getWorldPosition(originWorld)
|
||||
const originalRotation = node.rotation[1] ?? 0
|
||||
centerOffsetLocalRef.current = center
|
||||
.clone()
|
||||
.sub(originWorld)
|
||||
.applyAxisAngle(Y_AXIS, -originalRotation)
|
||||
return [center.x, 0, center.z]
|
||||
}
|
||||
const pos = new THREE.Vector3()
|
||||
obj.getWorldPosition(pos)
|
||||
return [pos.x, pos.y, pos.z]
|
||||
@@ -45,9 +66,22 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
useEffect(() => {
|
||||
const nodeId = nodeIdRef.current
|
||||
const originalPosition = originalPositionRef.current
|
||||
const offsetWork = new THREE.Vector3()
|
||||
const offsetAt = (rotationY: number) =>
|
||||
offsetWork.copy(centerOffsetLocalRef.current).applyAxisAngle(Y_AXIS, rotationY)
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
// Publish the building's current pose to useLiveTransforms so the
|
||||
// floor-plan (and any other live consumers) can follow per-frame
|
||||
// without peeking into the Three.js mesh.
|
||||
const publishLive = (posX: number, posZ: number, rotY: number) => {
|
||||
useLiveTransforms.getState().set(nodeId, {
|
||||
position: [posX, originalPosition[1], posZ],
|
||||
rotation: rotY,
|
||||
})
|
||||
}
|
||||
|
||||
let wasCommitted = false
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -66,7 +100,19 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
pendingRotationRef.current += rotationDelta
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(nodeId)
|
||||
if (mesh) mesh.rotation.y = pendingRotationRef.current
|
||||
if (mesh) {
|
||||
mesh.rotation.y = pendingRotationRef.current
|
||||
// Keep the bbox center pinned to the cursor through rotation.
|
||||
if (previousGridPosRef.current) {
|
||||
const [gridX, gridZ] = previousGridPosRef.current
|
||||
const off = offsetAt(pendingRotationRef.current)
|
||||
mesh.position.x = gridX - off.x
|
||||
mesh.position.z = gridZ - off.z
|
||||
publishLive(mesh.position.x, mesh.position.z, pendingRotationRef.current)
|
||||
} else {
|
||||
publishLive(mesh.position.x, mesh.position.z, pendingRotationRef.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +133,10 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
// Directly update the Three.js group — no store update during drag
|
||||
const mesh = sceneRegistry.nodes.get(nodeId)
|
||||
if (mesh) {
|
||||
mesh.position.x = gridX
|
||||
mesh.position.z = gridZ
|
||||
const off = offsetAt(pendingRotationRef.current)
|
||||
mesh.position.x = gridX - off.x
|
||||
mesh.position.z = gridZ - off.z
|
||||
publishLive(mesh.position.x, mesh.position.z, pendingRotationRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +146,10 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
|
||||
wasCommitted = true
|
||||
|
||||
const off = offsetAt(pendingRotationRef.current)
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(nodeId, {
|
||||
position: [gridX, originalPosition[1], gridZ],
|
||||
position: [gridX - off.x, originalPosition[1], gridZ - off.z],
|
||||
rotation: [0, pendingRotationRef.current, 0],
|
||||
})
|
||||
useScene.temporal.getState().pause()
|
||||
@@ -140,6 +189,10 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
rotation: [0, originalRotationRef.current, 0],
|
||||
})
|
||||
}
|
||||
// Drop the live transform — committed positions are now in the scene
|
||||
// store, so the floor-plan should read those instead of the stale
|
||||
// drag overlay.
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
|
||||
@@ -11,8 +11,8 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
findWallSnapTarget,
|
||||
getWallAngleSnapStep,
|
||||
getWallGridStep,
|
||||
isWallLongEnough,
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
snapPointTo45Degrees,
|
||||
snapPointToGrid,
|
||||
type WallPlanPoint,
|
||||
@@ -131,9 +131,11 @@ export function snapFenceDraftPoint(args: {
|
||||
start?: FencePlanPoint
|
||||
angleSnap?: boolean
|
||||
ignoreFenceIds?: string[]
|
||||
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
|
||||
step?: number
|
||||
}): FencePlanPoint {
|
||||
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
|
||||
const gridStep = getWallGridStep()
|
||||
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step } = args
|
||||
const gridStep = step ?? getSegmentGridStep()
|
||||
const angleStep = getWallAngleSnapStep(gridStep)
|
||||
const basePoint =
|
||||
start && angleSnap
|
||||
@@ -151,7 +153,7 @@ export function createFenceOnCurrentLevel(
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
||||
if (!(currentLevelId && isSegmentLongEnough(start, end))) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +341,16 @@ export const MoveRoofTool: React.FC<{
|
||||
// Clear ephemeral live transform
|
||||
useLiveTransforms.getState().clear(movingNode.id)
|
||||
|
||||
if (!(wasCommitted || wasCancelled || isNew)) {
|
||||
// Skip restore when the 2D floor-plan overlay claimed teardown
|
||||
// ownership — same contract `FloorplanRegistryMoveOverlay` uses to
|
||||
// decide whether to revert its own apply() writes. Without this,
|
||||
// a stair / roof move committed in the floor plan unmounts this
|
||||
// tool with `wasCommitted === false` (this tool's own grid-click
|
||||
// never fired), and the restore below stomps the just-committed
|
||||
// position back to the snapshot.
|
||||
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
||||
|
||||
if (!(wasCommitted || wasCancelled || isNew || finalisedBy2D)) {
|
||||
useScene.getState().updateNode(movingNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
|
||||
@@ -1,12 +1,55 @@
|
||||
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||
import { createPortal } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
|
||||
import {
|
||||
BufferGeometry,
|
||||
ExtrudeGeometry,
|
||||
Float32BufferAttribute,
|
||||
type Line,
|
||||
type Object3D,
|
||||
Shape,
|
||||
} from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { snapToHalf } from '../item/placement-math'
|
||||
|
||||
const Y_OFFSET = 0.02
|
||||
// Per-side resize arrows: indigo chevrons that match the registry arrow
|
||||
// handles (wall / column / fence). Each arrow sits just outside an edge
|
||||
// midpoint, pointing along the edge's outward normal — dragging an arrow
|
||||
// translates that edge only (its two vertices), leaving the opposite side
|
||||
// fixed. Reuses the existing 'edge' drag mode in PolygonEditor.
|
||||
const EDGE_ARROW_COLOR = '#8381ed'
|
||||
const EDGE_ARROW_HOVER_COLOR = '#a5b4fc'
|
||||
const EDGE_ARROW_SCALE = 0.65
|
||||
const EDGE_ARROW_OFFSET = 0.34
|
||||
|
||||
function createEdgeArrowGeometry() {
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0.22, 0)
|
||||
shape.lineTo(-0.04, 0.12)
|
||||
shape.lineTo(-0.04, 0.035)
|
||||
shape.lineTo(-0.2, 0.035)
|
||||
shape.lineTo(-0.2, -0.035)
|
||||
shape.lineTo(-0.04, -0.035)
|
||||
shape.lineTo(-0.04, -0.12)
|
||||
shape.lineTo(0.22, 0)
|
||||
const geometry = new ExtrudeGeometry(shape, {
|
||||
depth: 0.08,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: 0.035,
|
||||
bevelSize: 0.03,
|
||||
bevelOffset: 0,
|
||||
bevelSegments: 10,
|
||||
curveSegments: 16,
|
||||
steps: 1,
|
||||
})
|
||||
geometry.translate(0, 0, -0.04)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
isDragging: boolean
|
||||
@@ -23,6 +66,14 @@ export interface PolygonEditorProps {
|
||||
polygon: Array<[number, number]>
|
||||
color?: string
|
||||
onPolygonChange: (polygon: Array<[number, number]>) => void
|
||||
/**
|
||||
* Fires on every drag tick with the in-flight polygon, then once with
|
||||
* `null` when the drag commits or is otherwise cleared. Hosts wire
|
||||
* this to `useLiveNodeOverrides` so the underlying mesh rebuilds at
|
||||
* pointer rate while `onPolygonChange` stays a single store commit
|
||||
* on release.
|
||||
*/
|
||||
onPolygonPreview?: (polygon: ReadonlyArray<readonly [number, number]> | null) => void
|
||||
minVertices?: number
|
||||
/** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
|
||||
levelId?: string
|
||||
@@ -55,6 +106,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
polygon,
|
||||
color = '#3b82f6',
|
||||
onPolygonChange,
|
||||
onPolygonPreview,
|
||||
minVertices = 3,
|
||||
levelId,
|
||||
surfaceHeight = 0,
|
||||
@@ -105,9 +157,19 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
||||
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
||||
|
||||
const onPolygonPreviewRef = useRef(onPolygonPreview)
|
||||
useEffect(() => {
|
||||
onPolygonPreviewRef.current = onPolygonPreview
|
||||
}, [onPolygonPreview])
|
||||
|
||||
const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
|
||||
previewPolygonRef.current = nextPolygon
|
||||
setPreviewPolygon(nextPolygon)
|
||||
// Notify the host so it can mirror the in-flight polygon onto
|
||||
// `useLiveNodeOverrides` (drag rebuilds the mesh at pointer rate)
|
||||
// and clear that override when we hand `null` back at commit /
|
||||
// cancel / external-undo time.
|
||||
onPolygonPreviewRef.current?.(nextPolygon)
|
||||
}, [])
|
||||
|
||||
// Keep ref in sync
|
||||
@@ -159,6 +221,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
const edgeHandles = useMemo(() => {
|
||||
if (displayPolygon.length < 2) return []
|
||||
|
||||
let cx = 0
|
||||
let cz = 0
|
||||
for (const [x, z] of displayPolygon) {
|
||||
cx += x
|
||||
cz += z
|
||||
}
|
||||
cx /= displayPolygon.length
|
||||
cz /= displayPolygon.length
|
||||
|
||||
return displayPolygon.flatMap(([x1, z1], index) => {
|
||||
const nextIndex = (index + 1) % displayPolygon.length
|
||||
const [x2, z2] = displayPolygon[nextIndex]!
|
||||
@@ -167,17 +238,33 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length < 1e-6) return []
|
||||
|
||||
const midpoint: [number, number] = [(x1 + x2) / 2, (z1 + z2) / 2]
|
||||
// Outward normal: edge perpendicular flipped to point away from the
|
||||
// polygon centroid. Independent of winding order so arrows always
|
||||
// face outward even on hand-traced (mixed-winding) polygons.
|
||||
let nx = -dz / length
|
||||
let nz = dx / length
|
||||
if (nx * (midpoint[0] - cx) + nz * (midpoint[1] - cz) < 0) {
|
||||
nx = -nx
|
||||
nz = -nz
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
index,
|
||||
length,
|
||||
midpoint: [(x1 + x2) / 2, (z1 + z2) / 2] as [number, number],
|
||||
midpoint,
|
||||
rotationY: -Math.atan2(dz, dx),
|
||||
outwardNormal: [nx, nz] as [number, number],
|
||||
outwardAngle: -Math.atan2(nz, nx),
|
||||
},
|
||||
]
|
||||
})
|
||||
}, [displayPolygon])
|
||||
|
||||
const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), [])
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
|
||||
// Update vertex position using grid cursor position
|
||||
const handleVertexDrag = useCallback(
|
||||
(vertexIndex: number, position: [number, number]) => {
|
||||
@@ -427,7 +514,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
>
|
||||
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||
<meshStandardMaterial
|
||||
<meshBasicMaterial
|
||||
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
|
||||
/>
|
||||
</mesh>
|
||||
@@ -458,26 +545,18 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
|
||||
>
|
||||
<sphereGeometry args={[0.09, 20, 20]} />
|
||||
<meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
|
||||
<meshBasicMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{allowEdgeMove &&
|
||||
edgeHandles.map(({ index, length, midpoint, rotationY }) => {
|
||||
edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
|
||||
const isHovered = hoveredEdge === index
|
||||
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
|
||||
const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
|
||||
const arrowZ = midpoint[1] + outwardNormal[1] * EDGE_ARROW_OFFSET
|
||||
|
||||
return (
|
||||
<mesh
|
||||
key={`edge-${index}`}
|
||||
layers={EDITOR_LAYER}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
const beginEdgeDrag = (e: { button: number; pointerId: number }) => {
|
||||
const start = displayPolygon[index]
|
||||
const end = displayPolygon[(index + 1) % displayPolygon.length]
|
||||
if (!(start && end)) return
|
||||
@@ -496,6 +575,20 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
||||
pointerId: e.pointerId,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<group key={`edge-${index}`}>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
beginEdgeDrag(e)
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -509,12 +602,50 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
rotation={[0, rotationY, 0]}
|
||||
>
|
||||
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
|
||||
<meshStandardMaterial
|
||||
color={isDragging ? '#22c55e' : '#94a3b8'}
|
||||
<meshBasicMaterial
|
||||
color={isDragging ? '#22c55e' : '#60a5fa'}
|
||||
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
{/* Per-side resize arrow — points outward from the edge.
|
||||
Dragging it pulls (or pushes) only this edge's two
|
||||
vertices along the outward normal; the opposite side
|
||||
of the polygon stays put. */}
|
||||
<mesh
|
||||
geometry={arrowGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
beginEdgeDrag(e)
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredEdge(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredEdge(null)
|
||||
}}
|
||||
position={[arrowX, edgeHandleY, arrowZ]}
|
||||
rotation={[0, outwardAngle, 0]}
|
||||
scale={EDGE_ARROW_SCALE}
|
||||
>
|
||||
<meshBasicMaterial
|
||||
color={
|
||||
isDragging ? '#22c55e' : isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR
|
||||
}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -560,7 +691,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
>
|
||||
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||
<meshStandardMaterial
|
||||
<meshBasicMaterial
|
||||
color={isHovered ? '#4ade80' : '#22c55e'}
|
||||
opacity={isHovered ? 1 : 0.7}
|
||||
transparent
|
||||
|
||||
@@ -13,5 +13,5 @@ export const DEFAULT_SPIRAL_TOP_LANDING_MODE = 'none' as const
|
||||
export const DEFAULT_SPIRAL_TOP_LANDING_DEPTH = 0.9
|
||||
export const DEFAULT_SPIRAL_SHOW_CENTER_COLUMN = true
|
||||
export const DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS = true
|
||||
export const DEFAULT_STAIR_RAILING_MODE = 'right' as const
|
||||
export const DEFAULT_STAIR_RAILING_MODE = 'both' as const
|
||||
export const DEFAULT_STAIR_RAILING_HEIGHT = 0.92
|
||||
|
||||
@@ -19,7 +19,15 @@ import useEditor from '../../../store/use-editor'
|
||||
export type WallPlanPoint = [number, number]
|
||||
|
||||
export const WALL_GRID_STEP = 0.5
|
||||
// Smallest available grid snap. Used as a precision-mode step (Shift +
|
||||
// drag) so a drag can land on values the regular grid skips.
|
||||
export const WALL_FINE_GRID_STEP = 0.05
|
||||
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
||||
// Generous radius for snapping to an *existing* wall's endpoint while
|
||||
// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
|
||||
// is the strongest user intent (closing a polygon, attaching to a
|
||||
// corner) and the cursor never lands pixel-perfect on a corner.
|
||||
export const WALL_ENDPOINT_SNAP_RADIUS = 0.7
|
||||
export const WALL_MIN_LENGTH = 0.01
|
||||
const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
|
||||
|
||||
@@ -41,7 +49,7 @@ function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
||||
return dx * dx + dz * dz
|
||||
}
|
||||
|
||||
export function getWallGridStep(): number {
|
||||
export function getSegmentGridStep(): number {
|
||||
return useEditor.getState().gridSnapStep
|
||||
}
|
||||
|
||||
@@ -71,7 +79,7 @@ export function snapPointTo45Degrees(
|
||||
)
|
||||
}
|
||||
|
||||
export function getWallAngleSnapStep(step = getWallGridStep()): number {
|
||||
export function getWallAngleSnapStep(step = getSegmentGridStep()): number {
|
||||
return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
|
||||
}
|
||||
|
||||
@@ -371,15 +379,56 @@ export function findWallSnapTarget(
|
||||
return bestTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a
|
||||
* generous radius. Use this before `findWallSnapTarget` so the strong
|
||||
* "attach to an existing wall corner" intent isn't accidentally pushed
|
||||
* out of range by an interim grid snap that moved the cursor away from
|
||||
* the endpoint.
|
||||
*/
|
||||
function findWallEndpointFromRaw(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
ignoreWallIds?: string[],
|
||||
): WallPlanPoint | null {
|
||||
const ignored = new Set(ignoreWallIds ?? [])
|
||||
const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
|
||||
let best: WallPlanPoint | null = null
|
||||
let bestDistSq = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const wall of walls) {
|
||||
if (ignored.has(wall.id)) continue
|
||||
for (const corner of [wall.start, wall.end] as WallPlanPoint[]) {
|
||||
const d = distanceSquared(point, corner)
|
||||
if (d <= radiusSquared && d < bestDistSq) {
|
||||
best = corner
|
||||
bestDistSq = d
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function snapWallDraftPoint(args: {
|
||||
point: WallPlanPoint
|
||||
walls: WallNode[]
|
||||
start?: WallPlanPoint
|
||||
angleSnap?: boolean
|
||||
ignoreWallIds?: string[]
|
||||
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
|
||||
step?: number
|
||||
}): WallPlanPoint {
|
||||
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
|
||||
const step = getWallGridStep()
|
||||
const { point, walls, start, angleSnap = false, ignoreWallIds, step: overrideStep } = args
|
||||
|
||||
// Endpoint of an existing wall wins outright when the cursor is
|
||||
// anywhere within `WALL_ENDPOINT_SNAP_RADIUS` — closing a polygon or
|
||||
// attaching to a corner should "just work" without needing
|
||||
// pixel-perfect aim. Done from the raw cursor so it isn't masked by
|
||||
// an interim grid snap that nudged us out of range.
|
||||
const endpointSnap = findWallEndpointFromRaw(point, walls, ignoreWallIds)
|
||||
if (endpointSnap) return endpointSnap
|
||||
|
||||
const step = overrideStep ?? getSegmentGridStep()
|
||||
const angleStep = getWallAngleSnapStep(step)
|
||||
const basePoint =
|
||||
start && angleSnap
|
||||
@@ -393,7 +442,7 @@ export function snapWallDraftPoint(args: {
|
||||
)
|
||||
}
|
||||
|
||||
export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
|
||||
export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
|
||||
return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
|
||||
}
|
||||
|
||||
@@ -405,7 +454,7 @@ export function createWallOnCurrentLevel(
|
||||
const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
|
||||
const { updateNodes } = useScene.getState()
|
||||
|
||||
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
||||
if (!(currentLevelId && isSegmentLongEnough(start, end))) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -444,7 +493,7 @@ export function createWallOnCurrentLevel(
|
||||
resolvedStart = splitStart.point
|
||||
}
|
||||
|
||||
if (!isWallLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
|
||||
if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { getLevelDisplayName } from '../../../lib/level-name'
|
||||
import { createLocalGuideImage } from '../../../lib/local-guide-image'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
|
||||
@@ -84,10 +85,6 @@ function useLowerReferenceLevels(): LevelNode[] {
|
||||
)
|
||||
}
|
||||
|
||||
function getLevelDisplayName(level: LevelNode) {
|
||||
return level.name || `Level ${level.level}`
|
||||
}
|
||||
|
||||
// ── Shared upload button for dropdowns ──────────────────────────────────────
|
||||
|
||||
function UploadButton({ onError }: { onError: (message: string | null) => void }) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import { useShallow } from 'zustand/shallow'
|
||||
import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog'
|
||||
import { getLevelDisplayName } from '../../../lib/level-name'
|
||||
import { useCommandRegistry } from '../../../store/use-command-registry'
|
||||
import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry'
|
||||
|
||||
@@ -404,7 +405,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
||||
<OptionItem
|
||||
isActive={level.id === activeLevelId}
|
||||
key={level.id}
|
||||
label={level.name ?? `Level ${level.level}`}
|
||||
label={getLevelDisplayName(level)}
|
||||
onSelect={() =>
|
||||
run(() => useViewer.getState().setSelection({ levelId: level.id }))
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
buildLevelDuplicateCreateOps,
|
||||
type LevelDuplicatePreset,
|
||||
} from '../../lib/level-duplication'
|
||||
import { getDefaultLevelName, getLevelDisplayName } from '../../lib/level-name'
|
||||
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
|
||||
import {
|
||||
getEditorClipboardSnapshot,
|
||||
@@ -60,10 +61,6 @@ import {
|
||||
} from './primitives/dialog'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover'
|
||||
|
||||
function getLevelDisplayLabel(level: LevelNode) {
|
||||
return level.name || `Level ${level.level}`
|
||||
}
|
||||
|
||||
// ── Inline rename input for a level row ─────────────────────────────────────
|
||||
|
||||
function LevelInlineRename({
|
||||
@@ -76,7 +73,7 @@ function LevelInlineRename({
|
||||
onStopEditing: () => void
|
||||
}) {
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const defaultName = `Level ${level.level}`
|
||||
const defaultName = getDefaultLevelName(level.level)
|
||||
const [value, setValue] = useState(level.name || '')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -169,7 +166,7 @@ function LevelRow({
|
||||
>
|
||||
<button
|
||||
{...dragHandleProps}
|
||||
aria-label={`Reorder ${getLevelDisplayLabel(level)}`}
|
||||
aria-label={`Reorder ${getLevelDisplayName(level)}`}
|
||||
className={cn(
|
||||
'ml-0.5 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
|
||||
isDragging && 'cursor-grabbing opacity-100',
|
||||
@@ -192,10 +189,10 @@ function LevelRow({
|
||||
e.stopPropagation()
|
||||
setIsEditing(true)
|
||||
}}
|
||||
title={getLevelDisplayLabel(level)}
|
||||
title={getLevelDisplayName(level)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{getLevelDisplayLabel(level)}</span>
|
||||
<span className="truncate">{getLevelDisplayName(level)}</span>
|
||||
</button>
|
||||
|
||||
{/* Vertical three-dot menu — inside the pill */}
|
||||
@@ -599,7 +596,7 @@ export function FloatingLevelSelector() {
|
||||
<DialogTitle>Delete level</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{' '}
|
||||
<strong>{deletingLevel ? getLevelDisplayLabel(deletingLevel) : ''}</strong>? All
|
||||
<strong>{deletingLevel ? getLevelDisplayName(deletingLevel) : ''}</strong>? All
|
||||
walls, floors, and objects on this level will be permanently removed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { LevelNode } from '@pascal-app/core'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { LevelDuplicatePreset } from '../../lib/level-duplication'
|
||||
import { getLevelDisplayName } from '../../lib/level-name'
|
||||
import { cn } from '../../lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -42,7 +43,7 @@ const DUPLICATE_PRESETS: Array<{
|
||||
|
||||
function getLevelLabel(level: LevelNode | null) {
|
||||
if (!level) return 'this level'
|
||||
return level.name || `Level ${level.level}`
|
||||
return getLevelDisplayName(level)
|
||||
}
|
||||
|
||||
export function LevelDuplicateDialog({
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
buildLevelDuplicateCreateOps,
|
||||
type LevelDuplicatePreset,
|
||||
} from './../../../../../lib/level-duplication'
|
||||
import { getDefaultLevelName } from './../../../../../lib/level-name'
|
||||
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
||||
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
||||
import { cn } from './../../../../../lib/utils'
|
||||
@@ -723,7 +724,7 @@ const LevelItem = memo(function LevelItem({
|
||||
src="/icons/level.png"
|
||||
/>
|
||||
<InlineRenameInput
|
||||
defaultName={`Level ${level.level}`}
|
||||
defaultName={getDefaultLevelName(level.level)}
|
||||
isEditing={isEditing}
|
||||
nodeId={level.id}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Layers } from 'lucide-react'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { getDefaultLevelName } from '../../../../../lib/level-name'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
@@ -35,7 +36,7 @@ export const LevelTreeNode = memo(function LevelTreeNode({
|
||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||
|
||||
const defaultName = `Level ${level}`
|
||||
const defaultName = getDefaultLevelName(level)
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { getLevelDisplayName } from '../lib/level-name'
|
||||
import { cn } from '../lib/utils'
|
||||
import { ActionButton } from './ui/action-menu/action-button'
|
||||
import {
|
||||
@@ -363,7 +364,7 @@ export const ViewerOverlay = ({
|
||||
className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
|
||||
onClick={() => handleBreadcrumbClick('level')}
|
||||
>
|
||||
{level.name || `Level ${level.level}`}
|
||||
{getLevelDisplayName(level)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
|
||||
import { toggleDoorOpenState } from '../lib/door-interaction'
|
||||
import { runRedo, runUndo } from '../lib/history'
|
||||
import {
|
||||
copySelectedNodesToEditorClipboard,
|
||||
pasteEditorClipboardToLevel,
|
||||
} from '../lib/scene-clipboard'
|
||||
import { sfxEmitter } from '../lib/sfx-bus'
|
||||
import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
|
||||
import { toggleWindowOpenState } from '../lib/window-interaction'
|
||||
import useEditor from '../store/use-editor'
|
||||
|
||||
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
||||
@@ -173,29 +173,33 @@ export const useKeyboard = ({
|
||||
}
|
||||
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
|
||||
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
||||
// Operable doors/windows use R to toggle their open/closed state.
|
||||
// Doors use R to flip side (front ↔ back, rotation += π); their
|
||||
// open/close toggle lives on E. Windows still use R to toggle
|
||||
// their open/closed state.
|
||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedNodeIds.length === 1) {
|
||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||
if (node?.type === 'door') {
|
||||
e.preventDefault()
|
||||
if (node.openingKind !== 'opening') {
|
||||
toggleDoorOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
useScene.getState().updateNode(node.id, {
|
||||
side: node.side === 'front' ? 'back' : 'front',
|
||||
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
||||
})
|
||||
if (node.parentId) {
|
||||
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
} else if (
|
||||
node?.type === 'window' &&
|
||||
node.openingKind !== 'opening' &&
|
||||
(node.windowType === 'sliding' ||
|
||||
node.windowType === 'casement' ||
|
||||
node.windowType === 'awning' ||
|
||||
node.windowType === 'hopper' ||
|
||||
node.windowType === 'single-hung' ||
|
||||
node.windowType === 'double-hung' ||
|
||||
node.windowType === 'louvered')
|
||||
) {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
} else if (node?.type === 'window') {
|
||||
// Windows: R flips side (front ↔ back, rotation += π). Open/
|
||||
// close toggle for operable windows lives on E.
|
||||
e.preventDefault()
|
||||
toggleWindowOpenState(node.id)
|
||||
useScene.getState().updateNode(node.id, {
|
||||
side: node.side === 'front' ? 'back' : 'front',
|
||||
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
||||
})
|
||||
if (node.parentId) {
|
||||
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.r?.appliesTo(node)) {
|
||||
// Registry-driven R action. Skylight uses this for open/
|
||||
@@ -227,25 +231,13 @@ export const useKeyboard = ({
|
||||
if (selectedNodeIds.length === 1) {
|
||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||
if (node?.type === 'door') {
|
||||
// Door's open/close moved to E; T is a no-op for doors so
|
||||
// it doesn't free-rotate a wall-bound node by π/4.
|
||||
e.preventDefault()
|
||||
if (node.openingKind !== 'opening') {
|
||||
closeDoorOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
} else if (
|
||||
node?.type === 'window' &&
|
||||
node.openingKind !== 'opening' &&
|
||||
(node.windowType === 'sliding' ||
|
||||
node.windowType === 'casement' ||
|
||||
node.windowType === 'awning' ||
|
||||
node.windowType === 'hopper' ||
|
||||
node.windowType === 'single-hung' ||
|
||||
node.windowType === 'double-hung' ||
|
||||
node.windowType === 'louvered')
|
||||
) {
|
||||
} else if (node?.type === 'window') {
|
||||
// Window's open/close moved to E; T is a no-op so it doesn't
|
||||
// free-rotate a wall-bound node by π/4.
|
||||
e.preventDefault()
|
||||
closeWindowOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.t?.appliesTo(node)) {
|
||||
// Registry-driven T action. Same shape as the R arm above.
|
||||
e.preventDefault()
|
||||
@@ -265,6 +257,32 @@ export const useKeyboard = ({
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
}
|
||||
} else if ((e.key === 'e' || e.key === 'E') && !isVersionPreviewMode) {
|
||||
// Toggle door / operable-window open/closed state. Moved off R,
|
||||
// which now flips the opening (side + π rotation).
|
||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedNodeIds.length === 1) {
|
||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||
if (node?.type === 'door' && node.openingKind !== 'opening') {
|
||||
e.preventDefault()
|
||||
toggleDoorOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
} else if (
|
||||
node?.type === 'window' &&
|
||||
node.openingKind !== 'opening' &&
|
||||
(node.windowType === 'sliding' ||
|
||||
node.windowType === 'casement' ||
|
||||
node.windowType === 'awning' ||
|
||||
node.windowType === 'hopper' ||
|
||||
node.windowType === 'single-hung' ||
|
||||
node.windowType === 'double-hung' ||
|
||||
node.windowType === 'louvered')
|
||||
) {
|
||||
e.preventDefault()
|
||||
toggleWindowOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
}
|
||||
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) {
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
@@ -81,11 +81,12 @@ export {
|
||||
} from './components/tools/stair/stair-defaults'
|
||||
export {
|
||||
createWallOnCurrentLevel,
|
||||
getWallGridStep,
|
||||
isWallLongEnough,
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
snapPointToGrid,
|
||||
snapScalarToGrid,
|
||||
snapWallDraftPoint,
|
||||
WALL_FINE_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from './components/tools/wall/wall-drafting'
|
||||
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
|
||||
@@ -158,6 +159,7 @@ export {
|
||||
type FloorplanStairArrowEntry,
|
||||
type FloorplanStairEntry,
|
||||
type FloorplanStairSegmentEntry,
|
||||
getFloorplanWallThickness,
|
||||
} from './lib/floorplan'
|
||||
export {
|
||||
buildRoofSurfaceMaterialPatch,
|
||||
@@ -192,3 +194,4 @@ export {
|
||||
usePaletteViewRegistry,
|
||||
} from './store/use-palette-view-registry'
|
||||
export { useUploadStore } from './store/use-upload'
|
||||
export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AnyNode, EditorApi, FenceNode, WallNode } from '@pascal-app/core'
|
||||
import useEditor from '../store/use-editor'
|
||||
|
||||
type EditorState = ReturnType<typeof useEditor.getState>
|
||||
type EndpointEngager = (node: AnyNode, endpoint: 'start' | 'end', editor: EditorState) => void
|
||||
|
||||
/**
|
||||
* Per-kind endpoint-move engagement. Kinds whose 2D endpoint drag
|
||||
* needs its own store field (wall ↔ `movingWallEndpoint`, fence ↔
|
||||
* `movingFenceEndpoint`) register their bridge here. The dispatcher
|
||||
* is a table lookup rather than an `if (type === 'wall')` chain so
|
||||
* adding a new endpoint-draggable kind is a one-line entry instead
|
||||
* of a new branch. Each entry casts the generic `AnyNode` to its
|
||||
* concrete kind — the lookup key already guarantees the type.
|
||||
*/
|
||||
const endpointEngagers: Record<string, EndpointEngager> = {
|
||||
wall: (node, endpoint, editor) =>
|
||||
editor.setMovingWallEndpoint({ wall: node as WallNode, endpoint }),
|
||||
fence: (node, endpoint, editor) =>
|
||||
editor.setMovingFenceEndpoint({ fence: node as FenceNode, endpoint }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete {@link EditorApi} backed by `useEditor`. Descriptors call into
|
||||
* editor state through this interface; the editor owns the actual setter
|
||||
* names so core stays decoupled.
|
||||
*
|
||||
* `engageMove` clears any in-progress endpoint drag or curve gesture so
|
||||
* the move tool takes over cleanly — mirrors the legacy bookkeeping that
|
||||
* lived inside `WallMoveArrowHandle.activateWallMove` / `FenceMoveArrowHandle`.
|
||||
*/
|
||||
export function createEditorApi(): EditorApi {
|
||||
return {
|
||||
engageMove(node: AnyNode) {
|
||||
const editor = useEditor.getState()
|
||||
// `setMovingNode` is typed against a narrower union than `AnyNode`
|
||||
// (every concrete kind enumerated). Descriptors pass any node; the
|
||||
// cast lets registry-driven move kinds through without forcing a
|
||||
// schema-level type widening.
|
||||
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
|
||||
editor.setMovingWallEndpoint(null)
|
||||
editor.setMovingFenceEndpoint(null)
|
||||
editor.setCurvingWall(null)
|
||||
editor.setCurvingFence(null)
|
||||
},
|
||||
engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
|
||||
endpointEngagers[node.type]?.(node, endpoint, useEditor.getState())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LevelNode } from '@pascal-app/core'
|
||||
|
||||
export function getDefaultLevelName(level: number): string {
|
||||
if (level === 0) return 'Ground Floor'
|
||||
if (level > 0) return `Floor ${level}`
|
||||
return `Basement ${-level}`
|
||||
}
|
||||
|
||||
export function getLevelDisplayName(level: Pick<LevelNode, 'name' | 'level'>): string {
|
||||
return level.name || getDefaultLevelName(level.level)
|
||||
}
|
||||
@@ -188,10 +188,41 @@ type EditorState = {
|
||||
| BuildingNode
|
||||
| null,
|
||||
) => void
|
||||
/**
|
||||
* Which view (2D floor plan or 3D viewer) most recently completed
|
||||
* the active move — set by the committing or cancelling side just
|
||||
* before clearing `movingNode`. Lets the *other* side's effect
|
||||
* cleanup skip its own restore-from-snapshot when the drag was
|
||||
* already finalised elsewhere (split view mounts both the 2D
|
||||
* overlay and the 3D move tool for the same `movingNode`).
|
||||
*
|
||||
* Reset to null when the next non-null `setMovingNode` starts a
|
||||
* fresh drag (so stale values from the previous drag don't poison
|
||||
* cleanups). Preserved across `setMovingNode(null)` so the
|
||||
* non-owning side's cleanup — which fires after the clear
|
||||
* propagates — can still read who finalised. Null while a drag
|
||||
* is in progress means "no side has claimed it yet" — both
|
||||
* cleanups then restore to their pre-drag snapshot, which is the
|
||||
* same baseline, so the result is idempotent.
|
||||
*/
|
||||
movingNodeOrigin: '2d' | '3d' | null
|
||||
setMovingNodeOrigin: (origin: '2d' | '3d' | null) => void
|
||||
movingWallEndpoint: MovingWallEndpoint | null
|
||||
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
|
||||
movingFenceEndpoint: MovingFenceEndpoint | null
|
||||
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
|
||||
/**
|
||||
* Generic per-kind handle drag state. Set by a node's resize handle
|
||||
* (height arrow, width arrow, rise / sweep / inner-radius for curved
|
||||
* stairs, …) at drag-start and cleared on drag-end. `label`
|
||||
* identifies which dimension the handle controls — measurement
|
||||
* overlays read it to render the right caption; the camera controls
|
||||
* use the truthy value to suppress one-finger pan-rotate. Replaces
|
||||
* the previous per-kind `resizing*` fields so adding a new resize
|
||||
* handle doesn't require a new store field.
|
||||
*/
|
||||
activeHandleDrag: { nodeId: AnyNodeId; label: string } | null
|
||||
setActiveHandleDrag: (drag: { nodeId: AnyNodeId; label: string } | null) => void
|
||||
curvingWall: WallNode | null
|
||||
setCurvingWall: (wall: WallNode | null) => void
|
||||
curvingFence: FenceNode | null
|
||||
@@ -597,11 +628,24 @@ const useEditor = create<EditorState>()(
|
||||
| StairSegmentNode
|
||||
| BuildingNode
|
||||
| null,
|
||||
setMovingNode: (node) => set({ movingNode: node }),
|
||||
setMovingNode: (node) =>
|
||||
set(
|
||||
node === null
|
||||
? // Preserve `movingNodeOrigin` across the clear so the
|
||||
// non-owning side's effect cleanup — which fires after
|
||||
// `setMovingNode(null)` propagates — can still read who
|
||||
// finalised. The next non-null `setMovingNode` resets it.
|
||||
{ movingNode: null }
|
||||
: { movingNode: node, movingNodeOrigin: null },
|
||||
),
|
||||
movingNodeOrigin: null as '2d' | '3d' | null,
|
||||
setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
|
||||
movingWallEndpoint: null,
|
||||
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
|
||||
movingFenceEndpoint: null,
|
||||
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
||||
activeHandleDrag: null,
|
||||
setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
|
||||
curvingWall: null,
|
||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
||||
curvingFence: null,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
/**
|
||||
* Ephemeral preview state for wall move's bridge walls.
|
||||
*
|
||||
* When the user drags a wall whose corner neighbours are off-axis to
|
||||
* the move direction, the junction planner emits a `bridgePlan` for
|
||||
* each — a new wall that would be inserted between the original and
|
||||
* new corner on commit. The 3D `MoveWallTool` already renders these
|
||||
* as translucent ghost meshes mid-drag; the 2D `wallFloorplanMoveTarget`
|
||||
* writes here so the floor-plan SVG layer can render the same hint.
|
||||
*
|
||||
* Writer: `packages/nodes/src/wall/floorplan-move.ts` (on each `apply`,
|
||||
* cleared on `commit` and by the move overlay's cleanup).
|
||||
* Reader: `packages/editor/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx`.
|
||||
*/
|
||||
export type WallMoveGhostBridge = {
|
||||
id: string
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
/** Plan-space thickness already passed through `getFloorplanWallThickness`. */
|
||||
thickness: number
|
||||
color: string
|
||||
}
|
||||
|
||||
type WallMoveGhostsState = {
|
||||
bridges: WallMoveGhostBridge[]
|
||||
setBridges: (bridges: WallMoveGhostBridge[]) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export const useWallMoveGhosts = create<WallMoveGhostsState>((set) => ({
|
||||
bridges: [],
|
||||
setBridges: (bridges) => set({ bridges }),
|
||||
clear: () => set({ bridges: [] }),
|
||||
}))
|
||||
@@ -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: 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: 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: 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,8 +279,23 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
// 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)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
if (roofType === 'gable') {
|
||||
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 new THREE.Vector3(0, depth, -rh).normalize()
|
||||
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') {
|
||||
// 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()
|
||||
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(lx >= 0 ? rh : -rh, slopeReach, 0).normalize()
|
||||
|
||||
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
|
||||
}
|
||||
const halfD = depth / 2
|
||||
return new THREE.Vector3(0, halfD, lz >= 0 ? rh : -rh).normalize()
|
||||
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,22 +205,12 @@ 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'
|
||||
}`}
|
||||
<div className="space-y-3">
|
||||
<ToggleControl
|
||||
checked={node.fillToFloor}
|
||||
label="Fill to floor"
|
||||
onChange={(checked) => handleUpdate({ fillToFloor: checked })}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{!node.fillToFloor && (
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
@@ -232,6 +223,7 @@ export default function StairSegmentPanel() {
|
||||
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]}
|
||||
radius={spiralColumnRadius}
|
||||
/>
|
||||
</mesh>
|
||||
) : 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)),
|
||||
]}
|
||||
midAngle={midAngle}
|
||||
spiralColumnRadius={spiralColumnRadius}
|
||||
thickness={thickness}
|
||||
/>
|
||||
</mesh>
|
||||
) : 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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user