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:
Sudhir Yadav
2026-05-27 14:06:06 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 open-pascal
parent 6b0fe5b99c
commit c00a2469fd
127 changed files with 10005 additions and 1452 deletions
@@ -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).
+7
View File
@@ -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'
+29 -10
View File
@@ -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: () => {},
+245
View File
@@ -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>[])
+17
View File
@@ -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,
+64 -3
View File
@@ -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: () => {},
+4
View File
@@ -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)
+194 -6
View File
@@ -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,91 +438,38 @@ export function FloatingActionMenu() {
}}
zIndexRange={[100, 0]}
>
<NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
? handleCurve
: undefined
}
onDelete={handleDelete}
onDuplicate={
node &&
node.type !== 'spawn' &&
!DELETE_ONLY_TYPES.includes(node.type) &&
!HOLE_TYPES.includes(node.type)
? 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 ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
<NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
? 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 &&
node.type !== 'spawn' &&
!DELETE_ONLY_TYPES.includes(node.type) &&
!HOLE_TYPES.includes(node.type)
? handleDuplicate
: 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}
>
<FloorplanRegistryLayer />
{/* 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 measurefitmeasure 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
+59 -11
View File
@@ -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
}
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)
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
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()
// 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)
}
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))
function createArrowHandleGeometry() {
// 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 geometry = new ExtrudeGeometry(shape, {
depth: 0.08,
bevelEnabled: true,
bevelThickness: 0.035,
bevelSize: 0.03,
bevelOffset: 0,
bevelSegments: 10,
curveSegments: 16,
steps: 1,
})
// 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,63 +545,107 @@ 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
const beginEdgeDrag = (e: { button: number; pointerId: number }) => {
const start = displayPolygon[index]
const end = displayPolygon[(index + 1) % displayPolygon.length]
if (!(start && end)) return
const edgeNormal = getEdgeNormal(start, end)
if (!edgeNormal) return
setHoveredEdge(null)
setDragState({
isDragging: true,
mode: 'edge',
vertexIndex: null,
edgeIndex: index,
edgeNormal,
initialPosition: cursorPosition,
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
pointerId: e.pointerId,
})
}
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 start = displayPolygon[index]
const end = displayPolygon[(index + 1) % displayPolygon.length]
if (!(start && end)) return
const edgeNormal = getEdgeNormal(start, end)
if (!edgeNormal) return
setHoveredEdge(null)
setDragState({
isDragging: true,
mode: 'edge',
vertexIndex: null,
edgeIndex: index,
edgeNormal,
initialPosition: cursorPosition,
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
pointerId: e.pointerId,
})
}}
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredEdge(index)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setHoveredEdge(null)
}}
position={[midpoint[0], edgeHandleY, midpoint[1]]}
rotation={[0, rotationY, 0]}
>
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
<meshStandardMaterial
color={isDragging ? '#22c55e' : '#94a3b8'}
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
transparent
/>
</mesh>
<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()
setHoveredEdge(index)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setHoveredEdge(null)
}}
position={[midpoint[0], edgeHandleY, midpoint[1]]}
rotation={[0, rotationY, 0]}
>
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
<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>
</>
)}
+53 -35
View File
@@ -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()
+5 -2
View File
@@ -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'
+50
View File
@@ -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())
},
}
}
+11
View File
@@ -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)
}
+45 -1
View File
@@ -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,
+34 -2
View File
@@ -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}
/>
+52 -1
View File
@@ -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.
+30 -2
View File
@@ -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}
/>
+286
View File
@@ -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 })
},
}
},
}
+136 -7
View File
@@ -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 }
})
}
+90 -4
View File
@@ -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>
+16 -2
View File
@@ -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)
+109 -1
View File
@@ -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 -7
View File
@@ -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
+23
View File
@@ -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
+3 -3
View File
@@ -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>
)
+16 -11
View File
@@ -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)
}
}
+161 -1
View File
@@ -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 })
},
}
},
}
+182 -99
View File
@@ -10,33 +10,26 @@ import {
} from '@pascal-app/core'
/**
* Stage C floor-plan emitter for elevator. Renders:
* Stage C floor-plan emitter for elevator. Architectural symbol style:
*
* - **Outer shaft footprint** — rotated rectangle (cab + wall thickness).
* - **Cab indicator** — inner rectangle showing the cab's position within
* the shaft. Highlighted when `runtime.currentLevelId` matches the
* active level (i.e. the car is *on this floor*).
* - **Door opening indicator** — a short marker on the front face
* spanning `doorWidth` so users can see which way the doors open.
* - **Selection / target / queued chrome** — selection stroke when
* the elevator is selected, accent stroke when the runtime targets
* this level (cab is travelling here) or this level is queued.
* - **Outer shaft outline** — outer face of the shaft wall.
* - **Inner shaft outline** — inner face of the shaft wall, offset
* inward by `shaftWallThickness`. The two outlines together read as
* a hollow wall in plan.
* - **Dashed X** — two diagonals across the shaft interior, the
* universal architectural mark for an elevator cab.
* - **Door opening** — two small jamb stubs flanking the opening at
* the front face, with a dashed line spanning the opening (the
* closed door).
* - **Selection / runtime chrome** — selected → palette stroke;
* cab-on-level → green tint on the X; target/queued → sky accent.
*
* Reads the elevator's live state via `useLiveNodeOverrides.getState()`
* (inspector edits) and `useInteractive.getState().elevators[id]`
* (runtime cab travel). Those reads are non-reactive on their own —
* `FloorplanRegistryLayer` subscribes to both stores so the layer
* re-renders when they change, propagating into this builder.
*
* Per-level served-level chips (the small floor-label badges on each
* shaft side) are not emitted yet — they need an HTML-overlay primitive
* in `FloorplanGeometry` to render properly (SVG `<text>` rotates with
* the plan, which mangles label legibility). Tracked as follow-up; the
* legacy `<FloorplanElevatorLayer>` still renders the chips for
* pre-registry builds while we figure out the right primitive shape.
* Reads live overrides (`useLiveNodeOverrides`) and runtime cab state
* (`useInteractive.elevators[id]`) non-reactively; the registry layer
* subscribes to both stores so this builder re-runs on change.
*/
const STAGE_LEVEL_FILTER_HIDE = true
const STAGE_LEVEL_FILTER_HIDE = false
export function buildElevatorFloorplan(
node: ElevatorNode,
@@ -65,48 +58,33 @@ export function buildElevatorFloorplan(
const cabDepth = Math.max(display.depth, 0.8)
const shaftWidth = Math.max(display.shaftWidth ?? display.width, cabWidth, 0.8)
const shaftDepth = Math.max(display.shaftDepth ?? display.depth, cabDepth, 0.8)
const doorWidth = Math.min(Math.max(display.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18)
const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness)
const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness)
const doorWidth = Math.min(Math.max(display.doorWidth, 0.45), shaftWidth - 0.24)
const outerHalfW = Math.max(0.1, shaftWidth / 2 + wallThickness)
const outerHalfD = Math.max(0.1, shaftDepth / 2 + wallThickness)
const innerHalfW = Math.max(0.05, shaftWidth / 2)
const innerHalfD = Math.max(0.05, shaftDepth / 2)
const center = { x: display.position[0], y: display.position[2] }
const cos = Math.cos(display.rotation)
const sin = Math.sin(display.rotation)
const rotate = (lx: number, ly: number): [number, number] => {
// Same clockwise convention as `rotatePlanVector` in editor — see
// `wiki/architecture/tools.md` for why every plan-space rotation
// uses this matrix and not the standard counter-clockwise one.
// Negated-rotation matrix (equivalent to rotating by `-rotation`)
// so SVG's CW-with-y-down `rotate` direction visually matches
// Three.js Y-rotation (CCW from a top-down view). Column / shelf /
// roof-segment do the same thing explicitly via `-node.rotation`
// passed to `rotatePlanVector`; this is the inline version.
return [lx * cos + ly * sin, -lx * sin + ly * cos]
}
// Outer shaft footprint corners.
const outerCorners: Array<readonly [number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
]
const outerPoints: FloorplanPoint[] = outerCorners.map(([lx, ly]) => {
const toPlan = (lx: number, ly: number): FloorplanPoint => {
const [rx, ry] = rotate(lx, ly)
return [center.x + rx, center.y + ry]
})
// Cab inner rectangle. The cab sits flush against the front face
// (-Z in local coords) so its center is `-shaftDepth/2 + cabDepth/2`
// away from shaft center.
const cabCenterLocalY = -shaftDepth / 2 + cabDepth / 2
const cabHalfW = cabWidth / 2
const cabHalfD = cabDepth / 2
const cabCorners: Array<readonly [number, number]> = [
[-cabHalfW, cabCenterLocalY - cabHalfD],
[cabHalfW, cabCenterLocalY - cabHalfD],
[cabHalfW, cabCenterLocalY + cabHalfD],
[-cabHalfW, cabCenterLocalY + cabHalfD],
}
const rectPoints = (halfW: number, halfD: number): FloorplanPoint[] => [
toPlan(-halfW, -halfD),
toPlan(halfW, -halfD),
toPlan(halfW, halfD),
toPlan(-halfW, halfD),
]
const cabPoints: FloorplanPoint[] = cabCorners.map(([lx, ly]) => {
const [rx, ry] = rotate(lx, ly)
return [center.x + rx, center.y + ry]
})
// Runtime state — current level / target level / queued.
const runtime = useInteractive.getState().elevators[node.id]
@@ -122,63 +100,120 @@ export function buildElevatorFloorplan(
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
// Stroke selection — selected wins, then runtime target / queued
// states get the accent palette colour so users can spot "the cab is
// coming here" at a glance.
const stroke =
showSelectedChrome && palette
? palette.selectedStroke
: isTargetLevel || isQueuedLevel
? '#0ea5e9'
: '#475569'
// Shaft fill — orange when selected, light slate otherwise. When the
// car is *on this level*, the cab indicator inside gets the highlight
// instead of the whole shaft (more legible).
const shaftFill = showSelectedChrome ? '#fed7aa' : '#cbd5e1'
const cabFill = isCarOnLevel ? '#22c55e' : showSelectedChrome ? '#fef3c7' : '#e2e8f0'
const cabStroke = isCarOnLevel ? '#15803d' : '#475569'
// Base ink: near-black architectural outline. Selection switches to
// the palette accent. Runtime state (car-on-level, target, queued)
// is conveyed via the served-level chip column when selected, not
// the main outline — so the floor-plan symbol stays neutral and
// matches the line weight of the other architectural elements.
const baseInk = '#111111'
const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseInk
const cabMarkInk = stroke
const children: FloorplanGeometry[] = []
// Outer shaft.
// Invisible hit-target — closed polygon over the full outer
// footprint so clicks anywhere inside the elevator (not just on the
// stroked outlines) select the node. The outer outline below is a
// polyline with `fill='none'`, so without this layer the interior
// would fall through to whatever sits beneath.
children.push({
kind: 'polygon',
points: outerPoints,
fill: shaftFill,
points: rectPoints(outerHalfW, outerHalfD),
fill: stroke,
fillOpacity: 0,
stroke: 'none',
strokeWidth: 0,
pointerEvents: 'all',
})
// Door geometry. The jambs are little U-shaped notches that hang
// BELOW the outer wall's bottom edge on either side of the door
// opening; the outer outline traces around them and breaks for the
// door in the middle.
const doorY = -outerHalfD
const effectiveDoorWidth = Math.min(doorWidth, innerHalfW * 2 - 0.16)
const jambInnerX = effectiveDoorWidth / 2
const jambWidth = Math.max(0.08, Math.min(wallThickness * 2.2, (outerHalfW - jambInnerX) * 0.55))
const jambOuterX = Math.min(jambInnerX + jambWidth, outerHalfW - 0.04)
const jambStubDepth = Math.max(0.06, wallThickness * 1.05)
// Outer outline — single polyline that traces clockwise from the
// left edge of the door opening, around the left jamb stub, along
// the bottom-left segment, up the left side, across the top, down
// the right side, along the bottom-right segment, around the right
// jamb stub, ending at the right edge of the door opening. The two
// endpoints frame the door gap; SVG `polyline` doesn't close.
const outerOutline: FloorplanPoint[] = [
toPlan(-jambInnerX, doorY),
toPlan(-jambInnerX, doorY - jambStubDepth),
toPlan(-jambOuterX, doorY - jambStubDepth),
toPlan(-jambOuterX, doorY),
toPlan(-outerHalfW, doorY),
toPlan(-outerHalfW, outerHalfD),
toPlan(outerHalfW, outerHalfD),
toPlan(outerHalfW, doorY),
toPlan(jambOuterX, doorY),
toPlan(jambOuterX, doorY - jambStubDepth),
toPlan(jambInnerX, doorY - jambStubDepth),
toPlan(jambInnerX, doorY),
]
children.push({
kind: 'polyline',
points: outerOutline,
fill: 'none',
stroke,
strokeWidth: showSelectedChrome ? 0.04 : 0.03,
strokeLinejoin: 'round',
opacity: 0.85,
strokeWidth: showSelectedChrome ? 0.035 : 0.025,
strokeLinejoin: 'miter',
strokeLinecap: 'butt',
})
// Cab inner rectangle.
// Inner outline — closed rectangle, the inner face of the shaft.
// The X diagonals terminate at its corners.
children.push({
kind: 'polygon',
points: cabPoints,
fill: cabFill,
fillOpacity: isCarOnLevel ? 0.85 : 0.55,
stroke: cabStroke,
strokeWidth: 0.018,
strokeLinejoin: 'round',
opacity: 0.92,
points: rectPoints(innerHalfW, innerHalfD),
fill: 'none',
stroke,
strokeWidth: 0.02,
strokeLinejoin: 'miter',
})
// Door opening indicator — a short line on the front edge centered
// on the cab. The legacy renders a more complex slide / center-open
// hint; this is the minimum useful signal.
const doorY = -halfDepth
const [doorStartX, doorStartY] = rotate(-doorWidth / 2, doorY)
const [doorEndX, doorEndY] = rotate(doorWidth / 2, doorY)
// Dashed X across the shaft interior — corner-to-corner of the
// inner rectangle, the universal elevator-cab mark.
const diagonals: Array<readonly [FloorplanPoint, FloorplanPoint]> = [
[toPlan(-innerHalfW, -innerHalfD), toPlan(innerHalfW, innerHalfD)],
[toPlan(innerHalfW, -innerHalfD), toPlan(-innerHalfW, innerHalfD)],
]
for (const [start, end] of diagonals) {
children.push({
kind: 'line',
x1: start[0],
y1: start[1],
x2: end[0],
y2: end[1],
stroke: cabMarkInk,
strokeWidth: 0.018,
strokeDasharray: '0.08 0.06',
strokeLinecap: 'butt',
opacity: 0.85,
})
}
// Dashed door line — sits on the outer wall line, spanning the gap
// between the two jamb stubs.
const doorStart = toPlan(-jambInnerX, doorY)
const doorEnd = toPlan(jambInnerX, doorY)
children.push({
kind: 'line',
x1: center.x + doorStartX,
y1: center.y + doorStartY,
x2: center.x + doorEndX,
y2: center.y + doorEndY,
stroke: isCarOnLevel ? '#15803d' : '#0f172a',
strokeWidth: 0.05,
strokeLinecap: 'round',
opacity: 0.92,
x1: doorStart[0],
y1: doorStart[1],
x2: doorEnd[0],
y2: doorEnd[1],
stroke: cabMarkInk,
strokeWidth: 0.02,
strokeDasharray: '0.08 0.06',
strokeLinecap: 'butt',
opacity: 0.9,
})
// Served-level chips — vertical column of marker circles + level
@@ -194,7 +229,7 @@ export function buildElevatorFloorplan(
const serviceOnlyLevelIds = new Set(display.serviceOnlyLevelIds ?? [])
const rangeStep = 0.18
const rangeHeight = Math.max(0, (serviceLevelIds.length - 1) * rangeStep)
const [rangeOffsetX, rangeOffsetY] = rotate(halfWidth + 0.38, 0)
const [rangeOffsetX, rangeOffsetY] = rotate(outerHalfW + 0.38, 0)
const rangeX = center.x + rangeOffsetX
const rangeBottomY = center.y + rangeOffsetY + rangeHeight / 2
const rangeTopY = center.y + rangeOffsetY - rangeHeight / 2
@@ -265,11 +300,59 @@ export function buildElevatorFloorplan(
}
}
// Selection chrome — orange move-handle dot at the centre, four
// perpendicular side resize-arrows ringing the outer shaft, and a
// rotate-arrow at the front-right corner. Side arrows drive `width`
// (X-axis) and `depth` (Z-axis) through `elevatorResizeAffordance`
// with `anchor: 'center'` — sister of the 3D `linear-resize` handles
// in `definition.ts`. Body move is reached via the centroid dot or
// the floating action menu's Move button.
if (isSelected) {
children.push({
kind: 'move-handle',
point: [display.position[0], display.position[2]],
})
const sideArrowOffset = 0.12
const rotateCornerOffset = 0.22
const cx = display.position[0]
const cz = display.position[2]
const sides: Array<{
local: [number, number]
localAngle: number
axis: 'x' | 'z'
side: 1 | -1
}> = [
{ local: [outerHalfW + sideArrowOffset, 0], localAngle: 0, axis: 'x', side: 1 },
{ local: [-(outerHalfW + sideArrowOffset), 0], localAngle: Math.PI, axis: 'x', side: -1 },
{ local: [0, outerHalfD + sideArrowOffset], localAngle: Math.PI / 2, axis: 'z', side: 1 },
{ local: [0, -(outerHalfD + sideArrowOffset)], localAngle: -Math.PI / 2, axis: 'z', side: -1 },
]
for (const side of sides) {
const [ox, oz] = rotate(side.local[0], side.local[1])
const [tx, tz] = rotate(Math.cos(side.localAngle), Math.sin(side.localAngle))
children.push({
kind: 'move-arrow',
point: [cx + ox, cz + oz],
angle: Math.atan2(tz, tx),
affordance: 'elevator-resize',
payload: { axis: side.axis, side: side.side },
})
}
// Rotate-arrow at the +X / +Z corner. `localAngle = π/4` puts the
// curved arrow's bow at the diagonal corner so it reads as a
// rotation gizmo around the elevator centre.
const cornerLocalX = outerHalfW + rotateCornerOffset
const cornerLocalZ = outerHalfD + rotateCornerOffset
const [cornerX, cornerZ] = rotate(cornerLocalX, cornerLocalZ)
const [radialX, radialZ] = rotate(1, 1)
children.push({
kind: 'rotate-arrow',
point: [cx + cornerX, cz + cornerZ],
angle: Math.atan2(radialZ, radialX),
affordance: 'elevator-rotate',
})
}
return { kind: 'group', children }
@@ -6,7 +6,12 @@ import {
useScene,
type WallNode,
} from '@pascal-app/core'
import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pascal-app/editor'
import {
type FencePlanPoint,
isSegmentLongEnough,
snapFenceDraftPoint,
WALL_FINE_GRID_STEP,
} from '@pascal-app/editor'
/**
* Phase 5 Stage D — move-fence-endpoint drag affordance.
@@ -23,7 +28,7 @@ import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pas
* - **apply**: writes the fence + linked fence endpoints into the
* scene. Drag-session paused history captures originals; cascade
* resolver fans dirty marks through `endpoint-match`.
* - **commit**: requires `hasChanged` && `isWallLongEnough(next)`.
* - **commit**: requires `hasChanged` && `isSegmentLongEnough(next)`.
* Performs the single-undo dance — revert to originals (snapshot),
* resume history, re-apply final draft — so the entire drag is one
* `Ctrl-Z` step. Returns false to reject; `createDragSession.cancel`
@@ -143,13 +148,15 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
preview: (ctx, point, modifiers) => {
const planPoint: FencePlanPoint = [point[0], point[1]]
// Endpoint move = grid snap only; the 45°-from-start angle snap
// is draft-only. Shift switches to the fine grid step for
// precision, mirroring the wall convention.
const snapped = snapFenceDraftPoint({
point: planPoint,
walls: ctx.levelWalls,
fences: ctx.levelFences,
start: ctx.fixedPoint,
angleSnap: !modifiers.shift,
ignoreFenceIds: [ctx.fenceId as string],
step: modifiers.shift ? WALL_FINE_GRID_STEP : undefined,
})
const nextStart = ctx.endpoint === 'start' ? snapped : ctx.fixedPoint
const nextEnd = ctx.endpoint === 'end' ? snapped : ctx.fixedPoint
@@ -189,7 +196,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
// fence/actions/curve.ts for the rationale (no-op drag must still
// push a pastState entry to avoid Ctrl-Z cancelling the fence
// creation that preceded the activation).
if (!isWallLongEnough(draft.start, draft.end)) return false
if (!isSegmentLongEnough(draft.start, draft.end)) return false
// Single-undo dance: revert to originals (paused history → no
// zundo record), resume history, then re-apply the final draft
+3 -3
View File
@@ -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)
+130 -6
View File
@@ -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)
)
},
}
+180
View File
@@ -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
}
+49 -1
View File
@@ -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
+20 -1
View File
@@ -266,6 +266,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
useViewer.getState().setSelection({ selectedIds: [fenceId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
// Claim teardown ownership so the 2D overlay doesn't redundantly
// revert the same baseline on its own cleanup. Mirrors wall's
// move-tool cancel path.
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode()
}
@@ -275,7 +279,22 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
return () => {
if (!wasCommitted) {
restoreOriginal()
// The 2D `FloorplanRegistryMoveOverlay` mounts in parallel with
// this 3D tool whenever the user enters fence move mode from the
// floor plan. When the 2D overlay commits via
// `fenceFloorplanMoveTarget.commit()` it calls
// `setMovingNode(null)`, which unmounts this tool. Our local
// `wasCommitted` is still false (its own `onGridClick` never
// ran), so a blind `restoreOriginal()` here would overwrite the
// just-committed new positions back to the originals — the
// "fence reverts on commit" symptom users see in the 2D view.
// The 2D overlay sets `movingNodeOrigin = '2d'` before clearing
// movingNode; respect that flag and skip the restore. Mirrors
// the wall move-tool's `finalisedBy2D` guard.
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
if (!finalisedBy2D) {
restoreOriginal()
}
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
+14 -6
View File
@@ -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
+23 -7
View File
@@ -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>
/>
)
}
+187 -2
View File
@@ -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 })
},
}
}
+91 -17
View File
@@ -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 }
+109 -1
View File
@@ -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 })
},
}
},
}
+87 -13
View File
@@ -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 }
}
+2 -2
View File
@@ -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',
+36 -2
View File
@@ -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}
/>
+44 -1
View File
@@ -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).
+29 -2
View File
@@ -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', () => {
+94 -26
View File
@@ -1,4 +1,10 @@
import { getActiveRoofHeight, type RoofSegmentNode, type SolarPanelNode } from '@pascal-app/core'
import {
getActiveRoofHeight,
getSegmentSlopeFrame,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
type SolarPanelNode,
} from '@pascal-app/core'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshStandardNodeMaterial } from 'three/webgpu'
@@ -217,35 +223,97 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe
return peakY - t * rh
}
// Outward normal for a roof surface tilting at angle θ in the horizontal
// direction (dx, dz). Derivation: the surface tangent vectors are the
// ridge axis (perpendicular to the fall line, horizontal) and the
// down-slope direction (cos θ horizontal + sin θ vertical). Crossing
// them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz),
// equivalently (dx · tan θ, 1, dz · tan θ) un-normalised.
function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 {
return new THREE.Vector3(dx * tan, 1, dz * tan).normalize()
}
export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 {
const { roofType, depth, width } = seg
const rh = getActiveRoofHeight(seg)
if (rh === 0) return new THREE.Vector3(0, 1, 0)
if (roofType === 'gable') {
const halfD = depth / 2
return new THREE.Vector3(0, halfD, lz >= 0 ? rh : -rh).normalize()
}
if (roofType === 'shed') {
return new THREE.Vector3(0, depth, -rh).normalize()
}
if (roofType === 'hip') {
// All four hip faces share the same slope angle, set by `rh` over
// `min(w/2, d/2)` (the eave-to-ridge horizontal reach perpendicular
// to the ridge — short axis for both the trapezoidal long faces and
// the triangular short faces). Using `depth/2` for the front/back
// normal and `width/2` for the sides was correct only when w == d;
// for any other aspect ratio it tilted the long-axis faces wrong.
const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0
const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
const slopeReach = Math.min(width, depth) / 2
if (fz >= fx) {
return new THREE.Vector3(0, slopeReach, lz >= 0 ? rh : -rh).normalize()
}
return new THREE.Vector3(lx >= 0 ? rh : -rh, slopeReach, 0).normalize()
const slope = getSegmentSlopeFrame(seg)
if (slope.activeRh === 0 || slope.tanTheta === 0) {
return new THREE.Vector3(0, 1, 0)
}
const primaryTan = slope.tanTheta
const halfW = width / 2
const halfD = depth / 2
return new THREE.Vector3(0, halfD, lz >= 0 ? rh : -rh).normalize()
// Ridge runs along X — slope falls in ±Z. Gambrel shares the gable
// dispatch (its kink-to-eave/lower tier is the primary slope frame).
if (roofType === 'gable' || roofType === 'gambrel') {
if (roofType === 'gambrel') {
// Tier-aware: the upper (shallower) face spans |z| < mz; the
// lower (steep) face spans mz < |z| ≤ halfD. Using primaryTan on
// the upper tier would tilt the ghost too steeply near the ridge.
const lowerWidthRatio =
seg.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio
const lowerHeightRatio =
seg.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio
const mz = halfD * lowerWidthRatio
if (Math.abs(lz) <= mz) {
const upperRise = slope.activeRh * (1 - lowerHeightRatio)
const upperRun = mz
const upperTan = upperRun > 0 ? upperRise / upperRun : 0
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan)
}
}
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
}
// Single slope falling toward +Z (ridge at -Z, eave at +Z).
if (roofType === 'shed') {
return buildSlopeNormal(0, 1, primaryTan)
}
// 4-sided slopes: the dominant axis chooses which face the point sits
// on. Hip is uniform across all four faces. Mansard has a steep outer
// band (primaryTan) and a shallow top inside the waist. Dutch has hip
// ends and gable sides — both share the same primaryTan from the
// slope frame, so directional dispatch is enough.
if (roofType === 'hip') {
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
}
if (roofType === 'mansard') {
const widthRatio = seg.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio
const heightRatio = seg.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio
const inset = Math.min(width, depth) * widthRatio
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
const onZ = fz >= fx
const inSteepBand = onZ ? Math.abs(lz) > halfD - inset : Math.abs(lx) > halfW - inset
let tan = primaryTan
if (!inSteepBand) {
// Top hip (shallow) above the waist — rises from the waist
// rectangle at fraction `heightRatio` of activeRh up to the peak.
const topRise = slope.activeRh * (1 - heightRatio)
const topRun = Math.max(0, Math.min(halfW, halfD) - inset)
tan = topRun > 0 ? topRise / topRun : 0
}
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan)
}
if (roofType === 'dutch') {
// Hip on the short-axis ends, gable on the long-axis sides. Both
// share the primary pitch on their primary (eave-band) face, so the
// approximation collapses to "pick the dominant axis."
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
}
return new THREE.Vector3(0, 1, 0)
}
// ─── Quaternion helper ───────────────────────────────────────────────
+96 -1
View File
@@ -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',
+19 -27
View File
@@ -16,6 +16,7 @@ import {
PanelWrapper,
SegmentedControl,
SliderControl,
ToggleControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
@@ -204,34 +205,25 @@ export default function StairSegmentPanel() {
</PanelSection>
<PanelSection title="Structure">
<div className="flex items-center justify-between px-1 py-1">
<span className="text-muted-foreground text-xs">Fill to floor</span>
<button
className={`relative h-5 w-10 rounded-full transition-colors ${
node.fillToFloor ? 'bg-blue-500' : 'bg-[#3e3e3e]'
}`}
onClick={() => handleUpdate({ fillToFloor: !node.fillToFloor })}
type="button"
>
<div
className={`absolute top-1 h-3 w-3 rounded-full bg-white transition-transform ${
node.fillToFloor ? 'left-6' : 'left-1'
}`}
/>
</button>
</div>
{!node.fillToFloor && (
<SliderControl
label="Thickness"
max={1}
min={0.05}
onChange={(v) => handleUpdate({ thickness: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
<div className="space-y-3">
<ToggleControl
checked={node.fillToFloor}
label="Fill to floor"
onChange={(checked) => handleUpdate({ fillToFloor: checked })}
/>
)}
{!node.fillToFloor && (
<SliderControl
label="Thickness"
max={1}
min={0.05}
onChange={(v) => handleUpdate({ thickness: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
/>
)}
</div>
</PanelSection>
<PanelSection title="Position">
+320 -1
View File
@@ -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 })
},
}
},
}
+106
View File
@@ -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
}
+230 -9
View File
@@ -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.
+121 -36
View File
@@ -4,6 +4,7 @@ import {
type AnyNodeId,
type StairNode,
type StairSegmentNode,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
@@ -52,8 +53,18 @@ type LandingChainNextStair = {
isTerminalLandingBeforeStair: boolean
}
export const StairRenderer = ({ node }: { node: StairNode }) => {
export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
const ref = useRef<THREE.Group>(null!)
// Merge any live drag override into the node so curved/spiral geometry
// (built declaratively in JSX below) rebuilds on every drag tick. The
// resize arrows publish to `useLiveNodeOverrides` and only commit to
// zustand on release — subscribing here turns those override writes
// into the React re-renders that drive the visible mesh update.
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(rawNode.id))
const node = useMemo<StairNode>(
() => (liveOverride ? ({ ...rawNode, ...liveOverride } as StairNode) : rawNode),
[rawNode, liveOverride],
)
const isSegmentBasedStair = node.stairType === 'straight'
useRegistry(node.id, 'stair', ref)
@@ -464,17 +475,11 @@ function CurvedStairBody({
return (
<group name={isSpiral ? 'spiral-stair' : 'curved-stair'}>
{isSpiral && (stair.showCenterColumn ?? true) ? (
<mesh
castShadow
<SpiralColumnMesh
height={spiralColumnHeight}
material={sideMaterial}
name="stair-side"
position={[0, spiralColumnHeight / 2, 0]}
receiveShadow
>
<cylinderGeometry
args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]}
/>
</mesh>
radius={spiralColumnRadius}
/>
) : null}
{Array.from({ length: stepCount }).map((_, index) => {
const currentHeight = stepHeight * (index + 1)
@@ -498,32 +503,13 @@ function CurvedStairBody({
position-y={stepY}
>
{isSpiral && (stair.showStepSupports ?? true) ? (
<mesh
castShadow
<SpiralStepSupportMesh
innerRadius={innerRadius}
material={sideMaterial}
name="stair-side"
position={[
Math.cos(midAngle) *
(spiralColumnRadius +
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 -
0.02),
Math.max(thickness * 0.55, 0.025) / 2,
Math.sin(midAngle) *
(spiralColumnRadius +
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 -
0.02),
]}
receiveShadow
rotation-y={-midAngle}
>
<boxGeometry
args={[
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04),
Math.max(thickness * 0.55, 0.025),
Math.max(0.04, Math.min(0.12, Math.max(thickness * 0.55, 0.025) * 1.5)),
]}
/>
</mesh>
midAngle={midAngle}
spiralColumnRadius={spiralColumnRadius}
thickness={thickness}
/>
) : null}
<CurvedStepMesh
endAngle={endAngle}
@@ -585,11 +571,110 @@ function CurvedStepMesh({
[endAngle, innerRadius, outerRadius, startAngle, stepHeight, thickness],
)
// Dispose the prior BufferGeometry as soon as a new one supersedes it.
// Resize drags (in 2D or 3D) rebuild this geometry every pointer move;
// without explicit disposal, WebGPU keeps a stale pipeline reference to
// the old vertex buffer and flags "Vertex buffer slot 0 required by
// [RenderPipeline ...MeshLambertNodeMaterial...] was not set" on the
// submit after the swap. Same mitigation as guide/renderer.tsx.
useEffect(
() => () => {
geometry.dispose()
},
[geometry],
)
return (
<mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow />
)
}
/**
* Spiral center column. The cylinder is rebuilt whenever
* `spiralColumnRadius` changes — i.e. on every tick of an inner-radius
* drag. We pass the geometry as a prop (avoiding R3F's empty-placeholder
* frame from inline JSX) and dispose the prior one on swap, matching the
* pattern in guide/renderer.tsx. Without this WebGPU flags
* "Vertex buffer slot 0 ... was not set" on Lambert mid-resize.
*/
function SpiralColumnMesh({
radius,
height,
material,
}: {
radius: number
height: number
material: THREE.Material | THREE.Material[]
}) {
const geometry = useMemo(
() => new THREE.CylinderGeometry(radius, radius, height, 10),
[radius, height],
)
useEffect(
() => () => {
geometry.dispose()
},
[geometry],
)
return (
<mesh
castShadow
geometry={geometry}
material={material}
name="stair-side"
position={[0, height / 2, 0]}
receiveShadow
/>
)
}
/**
* Spiral step support — the small box wedged between the column and the
* inner rim of each step. Same prop-+-dispose pattern as
* `SpiralColumnMesh`: the box dimensions change every inner-radius tick
* (`innerRadius - spiralColumnRadius`), so inline-JSX geometry would
* trigger the Lambert vertex-buffer error.
*/
function SpiralStepSupportMesh({
innerRadius,
spiralColumnRadius,
midAngle,
thickness,
material,
}: {
innerRadius: number
spiralColumnRadius: number
midAngle: number
thickness: number
material: THREE.Material | THREE.Material[]
}) {
const sizeX = Math.max(0.04, innerRadius - spiralColumnRadius + 0.04)
const sizeY = Math.max(thickness * 0.55, 0.025)
const sizeZ = Math.max(0.04, Math.min(0.12, sizeY * 1.5))
const geometry = useMemo(
() => new THREE.BoxGeometry(sizeX, sizeY, sizeZ),
[sizeX, sizeY, sizeZ],
)
useEffect(
() => () => {
geometry.dispose()
},
[geometry],
)
const radial = spiralColumnRadius + sizeX / 2 - 0.02
return (
<mesh
castShadow
geometry={geometry}
material={material}
name="stair-side"
position={[Math.cos(midAngle) * radial, sizeY / 2, Math.sin(midAngle) * radial]}
receiveShadow
rotation-y={-midAngle}
/>
)
}
function buildCurvedStepGeometry(
innerRadius: number,
outerRadius: number,
+2 -2
View File
@@ -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)
+7
View File
@@ -1,6 +1,8 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildWallFloorplan } from './floorplan'
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
import { wallFloorplanMoveTarget } from './floorplan-move'
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
import { wallPaint } from './paint'
import { wallParametrics } from './parametrics'
import { WallNode } from './schema'
@@ -64,6 +66,9 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
},
parametrics: wallParametrics,
// Height arrow + side-move arrows + corner pickers all live in the legacy
// `wall-move-side-handles.tsx` component. The registry handle path didn't
// render correctly for walls specifically; revisit once that's diagnosed.
// Stage D — all four wall drag affordances live in this folder.
// curve / move-endpoint / move are 1:1 ports of the legacy tools
@@ -98,6 +103,8 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
'move-endpoint': wallMoveEndpointAffordance,
curve: wallCurveAffordance,
},
floorplanMoveTarget: wallFloorplanMoveTarget,
floorplanSiblingOverrides: wallFloorplanSiblingOverrides,
toolHints: [
{ key: 'Left click', label: 'Set wall start / end' },
@@ -6,14 +6,16 @@ import {
getMaxWallCurveOffset,
getWallChordFrame,
normalizeWallCurveOffset,
useLiveNodeOverrides,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
getWallGridStep,
isWallLongEnough,
getSegmentGridStep,
isSegmentLongEnough,
snapScalarToGrid,
snapWallDraftPoint,
WALL_FINE_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
@@ -29,9 +31,13 @@ import {
* ("linked walls").
* 2. On each tick: snap the moving point (grid → linked-wall → angle),
* compute primary endpoints, and cascade matching corners onto the
* linked walls. Apply via `useScene.updateNodes` — the registry
* dispatcher pauses / resumes history around this.
* 3. Commit if the resulting wall is still long enough (legacy guard).
* linked walls. Publish to `useLiveNodeOverrides` — `WallSystem`,
* the 2D floor-plan layer, and the sidebar panel all merge the
* overrides in when reading endpoints, so `useScene` never sees a
* mid-drag write.
* 3. On pointer-up: the dispatcher invokes `commit()`, which writes
* the final state to scene in one tracked update and clears the
* overrides. `canCommit` still guards against collapsed walls.
*
* Alt-detach (drop linked walls) and SHIFT-free-place (skip angle snap)
* are wired via the standard modifier flags on the session.
@@ -96,11 +102,13 @@ export const wallCurveAffordance: FloorplanAffordance<WallNode> = {
// pointer projection along its normal changes.
const chord = getWallChordFrame(node)
const maxOffset = getMaxWallCurveOffset(node)
const wallId = node.id as AnyNodeId
let lastCurveOffset = node.curveOffset ?? 0
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapStep = getWallGridStep()
const snapStep = getSegmentGridStep()
const x = modifiers.shiftKey ? planPoint[0] : snapScalarToGrid(planPoint[0], snapStep)
const y = modifiers.shiftKey ? planPoint[1] : snapScalarToGrid(planPoint[1], snapStep)
@@ -121,14 +129,27 @@ export const wallCurveAffordance: FloorplanAffordance<WallNode> = {
node,
Math.max(-maxOffset, Math.min(maxOffset, snappedOffset)),
)
lastCurveOffset = nextCurveOffset
useScene.getState().updateNodes([{ id: node.id, data: { curveOffset: nextCurveOffset } }])
// Publish the curve preview as a live override so renderers see
// it without zustand churn. Mark the wall dirty so `WallSystem`
// rebuilds the geometry next frame using the override-merged
// node.
useLiveNodeOverrides.getState().set(wallId, { curveOffset: nextCurveOffset })
useScene.getState().markDirty(wallId)
},
canCommit() {
// Curve drag is always commit-eligible — the offset is already
// clamped + normalized so we never end up in an invalid state.
return true
},
commit() {
// Atomic, tracked write of the final curve offset, then drop
// the override so the scene state is the single source of
// truth again.
useScene.getState().updateNodes([{ id: wallId, data: { curveOffset: lastCurveOffset } }])
useLiveNodeOverrides.getState().clear(wallId)
},
}
},
}
@@ -143,20 +164,31 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
const linkedWalls = collectLinkedWalls(nodes, node.id, originalStart, originalEnd)
const affectedIds: AnyNodeId[] = [node.id, ...linkedWalls.map((w) => w.id)]
// Remember the latest preview so `commit()` can write it tracked.
let lastPrimaryStart: WallPlanPoint = originalStart
let lastPrimaryEnd: WallPlanPoint = originalEnd
let lastLinkedUpdates: Array<{ id: AnyNodeId; start: WallPlanPoint; end: WallPlanPoint }> = []
return {
affectedIds,
apply({ planPoint, modifiers }) {
// Re-collect walls every tick so the snap pipeline sees fresh
// positions (matters when the user releases + re-grabs without
// unmounting the layer).
// unmounting the layer). Snap reads from scene — which holds
// the pre-drag positions throughout — so the linked-wall snap
// targets stay anchored to where corners *were*, exactly like
// the legacy flow.
const sceneNodes = useScene.getState().nodes
const walls = collectLevelWalls(sceneNodes, node.id)
// Endpoint move = grid snap, never 45° from the fixed corner —
// the angle snap is for initial draft only. Shift switches to
// the fine grid step for precision, matching the 3D
// `MoveWallEndpointTool`.
const snapped = snapWallDraftPoint({
point: planPoint as WallPlanPoint,
walls,
start: fixedPoint,
angleSnap: !modifiers.shiftKey,
ignoreWallIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
})
const primaryStart: WallPlanPoint = endpoint === 'start' ? snapped : fixedPoint
@@ -180,21 +212,42 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
: w.end,
}))
lastPrimaryStart = primaryStart
lastPrimaryEnd = primaryEnd
lastLinkedUpdates = linkedUpdates
// Publish overrides instead of writing to scene. WallSystem +
// 2D layer + sidebar panel merge these in. Marking dirty
// wakes the system's `useFrame` rebuild pass.
const overrides = useLiveNodeOverrides.getState()
const sceneState = useScene.getState()
overrides.set(node.id as AnyNodeId, { start: primaryStart, end: primaryEnd })
sceneState.markDirty(node.id as AnyNodeId)
for (const upd of linkedUpdates) {
overrides.set(upd.id, { start: upd.start, end: upd.end })
sceneState.markDirty(upd.id)
}
},
canCommit() {
// The dragged wall must still be long enough at the preview
// length — checked against `lastPrimary*`, not scene, because
// scene holds baseline values until commit().
return isSegmentLongEnough(lastPrimaryStart, lastPrimaryEnd)
},
commit() {
// Atomic tracked write of the final endpoints, then drop the
// overrides so the scene state is the single source of truth
// again.
useScene.getState().updateNodes([
{ id: node.id, data: { start: primaryStart, end: primaryEnd } },
...linkedUpdates.map((u) => ({
{ id: node.id, data: { start: lastPrimaryStart, end: lastPrimaryEnd } },
...lastLinkedUpdates.map((u) => ({
id: u.id,
data: { start: u.start, end: u.end },
})),
])
},
canCommit() {
const finalWall = useScene.getState().nodes[node.id] as WallNode | undefined
return (
!!finalWall &&
finalWall.type === 'wall' &&
isWallLongEnough(finalWall.start, finalWall.end)
)
const overrides = useLiveNodeOverrides.getState()
overrides.clear(node.id as AnyNodeId)
for (const upd of lastLinkedUpdates) overrides.clear(upd.id)
},
}
},
+332
View File
@@ -0,0 +1,332 @@
import {
type AnyNode,
type AnyNodeId,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
getPerpendicularWallMoveAxis,
getPlannedLinkedWallUpdates,
planWallMoveJunctions,
useLiveNodeOverrides,
useScene,
type WallNode,
type WallPlanPoint,
} from '@pascal-app/core'
import {
getFloorplanWallThickness,
getSegmentGridStep,
isSegmentLongEnough,
snapScalarToGrid,
useWallMoveGhosts,
type WallMoveGhostBridge,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import {
buildBridgeWallCreates,
buildBridgeWallPreviews,
getLinkedWallSnapshots,
getWallsAfterUpdates,
type LinkedWallSnapshot,
stripWallIsNewMetadata,
} from './move-shared'
/**
* 2D floor-plan move handler for wall.
*
* Mirrors the 3D `MoveWallTool` junction-plan behavior so dragging a
* wall in the floor plan produces the same scene topology as dragging
* it in 3D: linked corners cascade, same-direction collapsed walls
* delete, off-axis branches stay rectilinear with a new bridge wall
* inserted between the original and new corner.
*
* Tick (`apply`) — publishes per-frame `{ start, end }` overrides to
* `useLiveNodeOverrides` for the moved wall + every linked wall.
* `useScene` stays at the pre-drag values throughout the drag; the
* `WallSystem` and the 2D floor-plan layer fold the overrides in when
* reading endpoints, so the visual preview updates without churning
* zustand. Bridge creates and wall deletes are still deferred to
* commit so the live preview doesn't churn the scene graph either.
*
* Commit (`commit`) — recomputes the plan at the final cursor position
* and emits one atomic `applyNodeChanges` covering the moved walls,
* the bridge wall creates, and the collapsed wall deletes — so a
* single Ctrl-Z rolls the entire operation back. Clears the live
* overrides after the write lands so the system reads from the new
* committed scene state.
*
* Auto-slab live preview and ghost bridge SVG previews — visible in
* the 3D tool — are deliberately deferred. Slab polygons re-derive on
* commit through the normal scene reactions; bridges appear at commit
* time. Follow-up work to surface them mid-drag is tracked separately.
*/
export const wallFloorplanMoveTarget: FloorplanMoveTarget<WallNode> = ({ node }) => {
const wallId = node.id as AnyNodeId
const originalStart: WallPlanPoint = [node.start[0], node.start[1]]
const originalEnd: WallPlanPoint = [node.end[0], node.end[1]]
const originalCenter: WallPlanPoint = [
(originalStart[0] + originalEnd[0]) / 2,
(originalStart[1] + originalEnd[1]) / 2,
]
// Perpendicular (wall normal) axis — same lock the 3D `MoveWallTool`
// uses, so a 2D drag slides the wall sideways across its own
// direction rather than free-floating in XZ. `null` only for
// degenerate zero-length walls; we fall through to free motion in
// that case (the wall is going to be deleted anyway).
const moveAxis = getPerpendicularWallMoveAxis(originalStart, originalEnd)
const isNew = !!(node.metadata as { isNew?: unknown } | null)?.isNew
const linkedOriginals: LinkedWallSnapshot[] = isNew
? []
: getLinkedWallSnapshots({
wallId: node.id,
wallParentId: node.parentId ?? null,
originalStart,
originalEnd,
})
// Anchor lazily at the first cursor sample (the click point) and
// measure cursor movement from there. The wall doesn't shift on
// click — `rawDelta` is zero on the first apply, so the snapped
// centre projection equals `originalProj` (already grid-aligned for
// walls placed via the draft tool). As the cursor moves, the wall
// translates along its normal by the cursor's perpendicular delta,
// snapped to absolute grid lines.
//
// Using `originalCenter` as the anchor instead would pin the wall's
// centre to the cursor — but the floor-plan drag icon lives inside
// a floating menu whose hit-area is offset from the exact wall
// centre, so the first `planPoint` would never equal `originalCenter`
// and the wall would snap to a different grid cell the instant the
// drag began, even before the cursor moved.
let rawAnchor: WallPlanPoint | null = null
let lastDelta: WallPlanPoint = [0, 0]
let lastNextStart: WallPlanPoint = originalStart
let lastNextEnd: WallPlanPoint = originalEnd
const session: FloorplanMoveTargetSession = {
affectedIds: [wallId, ...linkedOriginals.map((w) => w.id as AnyNodeId)],
apply({ planPoint, modifiers }) {
if (!rawAnchor) {
rawAnchor = [planPoint[0], planPoint[1]]
return
}
const rawDx = planPoint[0] - rawAnchor[0]
const rawDz = planPoint[1] - rawAnchor[1]
const step = getSegmentGridStep()
// Axis-lock the move to the wall's perpendicular (normal). Project
// the original centre + raw cursor delta onto the axis, snap the
// absolute projection to a grid multiple, then translate the wall
// by `axis * perpDelta`. Matches `MoveWallTool` so 2D and 3D drag
// produce identical wall topology. Shift bypasses snap.
let dx: number
let dz: number
if (moveAxis) {
const originalProj = originalCenter[0] * moveAxis[0] + originalCenter[1] * moveAxis[1]
const rawProj = originalProj + rawDx * moveAxis[0] + rawDz * moveAxis[1]
const snappedProj = modifiers.shiftKey ? rawProj : snapScalarToGrid(rawProj, step)
const perpDelta = snappedProj - originalProj
dx = moveAxis[0] * perpDelta
dz = moveAxis[1] * perpDelta
} else {
dx = modifiers.shiftKey ? rawDx : snapScalarToGrid(rawDx, step)
dz = modifiers.shiftKey ? rawDz : snapScalarToGrid(rawDz, step)
}
if (dx === lastDelta[0] && dz === lastDelta[1]) return
lastDelta = [dx, dz]
const nextStart: WallPlanPoint = [originalStart[0] + dx, originalStart[1] + dz]
const nextEnd: WallPlanPoint = [originalEnd[0] + dx, originalEnd[1] + dz]
lastNextStart = nextStart
lastNextEnd = nextEnd
const plan = planWallMoveJunctions(
linkedOriginals,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
const plannedUpdates = getPlannedLinkedWallUpdates(
plan,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
const plannedById = new Map(plannedUpdates.map((entry) => [entry.id, entry]))
// Walls in the plan that would collapse to ≈zero-length get held
// at their pre-drag positions during the live preview — actually
// deleting them is deferred to commit so a single drag tick
// doesn't churn the scene graph with create / delete pairs.
const collapsedIds = new Set([
...plannedUpdates
.filter((entry) => !isSegmentLongEnough(entry.start, entry.end))
.map((entry) => entry.id),
...plan.wallsToDelete.map((wall) => wall.id),
])
const linkedUpdates = linkedOriginals.map((wall) => {
if (collapsedIds.has(wall.id)) {
return { id: wall.id as AnyNodeId, start: wall.start, end: wall.end }
}
const planned = plannedById.get(wall.id)
if (planned) {
return { id: wall.id as AnyNodeId, start: planned.start, end: planned.end }
}
return { id: wall.id as AnyNodeId, start: wall.start, end: wall.end }
})
// Publish endpoint overrides instead of writing to `useScene`.
// `WallSystem` + the 2D layer + the sidebar panel all consult
// `useLiveNodeOverrides` first, so the visual + numeric preview
// updates each frame while zustand stays at the pre-drag values
// until the user commits. Batched into a single zustand
// notification — otherwise each per-wall `.set` would re-render
// every override subscriber once per linked wall per tick.
useLiveNodeOverrides.getState().setMany([
[wallId, { start: nextStart, end: nextEnd }],
...linkedUpdates.map(
(upd) =>
[upd.id, { start: upd.start, end: upd.end }] as [string, Record<string, unknown>],
),
])
// Surface bridge-wall previews so the floor-plan SVG layer can
// render dashed outlines of what `commit()` will insert. Mirrors
// the 3D `MoveWallTool`'s `ghostWallPreviews` mesh layer. The
// `existingWalls` snapshot needs the post-drag layout so duplicate
// detection works; we synthesise it from scene state + the
// overrides we just published (zustand is still on baseline).
const previewSceneWalls = getWallsAfterUpdates(useScene.getState().nodes, [
{ id: wallId, data: { start: nextStart, end: nextEnd } },
...linkedUpdates.map((u) => ({ id: u.id, data: { start: u.start, end: u.end } })),
]).filter((wall) => !collapsedIds.has(wall.id))
const bridgePreviews = buildBridgeWallPreviews({
bridgePlans: plan.bridgePlans,
nextStart,
nextEnd,
existingWalls: previewSceneWalls,
})
const ghostBridges: WallMoveGhostBridge[] = bridgePreviews.map(({ ghost, wall }) => ({
id: ghost.id,
start: ghost.start,
end: ghost.end,
color: ghost.color,
thickness: getFloorplanWallThickness(wall),
}))
useWallMoveGhosts.getState().setBridges(ghostBridges)
// `WallSystem` only runs its rebuild pass when `dirtyNodes` is
// non-empty. We're not writing to scene any more, but we still
// need the system to pick up the live override changes — so
// mark dirty here. The rebuild reads the effective endpoints
// (override-merged) so the mesh follows the cursor.
const sceneState = useScene.getState()
sceneState.markDirty(wallId)
for (const upd of linkedUpdates) {
sceneState.markDirty(upd.id)
}
},
canCommit() {
const live = useScene.getState().nodes[wallId] as WallNode | undefined
if (!live || live.type !== 'wall') return false
const [dx, dz] = lastDelta
return dx !== 0 || dz !== 0
},
commit() {
const sceneState = useScene.getState()
const liveWall = sceneState.nodes[wallId] as WallNode | undefined
if (!liveWall || liveWall.type !== 'wall') {
// Bail without leaving stale overrides behind.
const overrides = useLiveNodeOverrides.getState()
overrides.clear(wallId)
for (const wall of linkedOriginals) overrides.clear(wall.id as AnyNodeId)
return
}
const plan = planWallMoveJunctions(
linkedOriginals,
originalStart,
originalEnd,
lastNextStart,
lastNextEnd,
)
const linkedWallUpdates = getPlannedLinkedWallUpdates(
plan,
originalStart,
originalEnd,
lastNextStart,
lastNextEnd,
)
const collapsedLinkedWallIds = new Set([
...linkedWallUpdates
.filter((entry) => !isSegmentLongEnough(entry.start, entry.end))
.map((entry) => entry.id as AnyNodeId),
...plan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
])
const movingWallUpdate = {
id: wallId,
data: isNew
? {
start: lastNextStart,
end: lastNextEnd,
metadata: stripWallIsNewMetadata(node.metadata),
}
: { start: lastNextStart, end: lastNextEnd },
}
const commitUpdates: Array<{ id: AnyNodeId; data: Partial<WallNode> }> = [
movingWallUpdate,
...linkedWallUpdates
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end } as Partial<WallNode>,
})),
]
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
(wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId),
)
const bridgeCreates = buildBridgeWallCreates({
bridgePlans: plan.bridgePlans,
nextStart: lastNextStart,
nextEnd: lastNextEnd,
existingWalls,
wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length,
})
sceneState.applyNodeChanges({
update: commitUpdates as Array<{ id: AnyNodeId; data: Partial<AnyNode> }>,
create: bridgeCreates,
delete: Array.from(collapsedLinkedWallIds),
})
// Drop the live overrides now that the committed scene state
// matches them — leaving them around would keep the system
// double-merging the same values and would shadow any genuine
// post-commit edits to start/end. Cleared synchronously so the
// next frame reads the new scene state directly.
const overrides = useLiveNodeOverrides.getState()
overrides.clear(wallId)
for (const wall of linkedOriginals) overrides.clear(wall.id as AnyNodeId)
// Swap ghosts → real walls: the bridges we just created render
// through the registry layer, so the dashed previews aren't
// needed any more. Clearing here (instead of waiting for the
// overlay's unmount cleanup) avoids a one-frame flash where
// both ghosts and real bridges paint at the new position.
useWallMoveGhosts.getState().clear()
useViewer.getState().setSelection({ selectedIds: [wallId] })
},
}
return session
}

Some files were not shown because too many files have changed in this diff Show More