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
+38 -3
View File
@@ -52,10 +52,12 @@ git push -u origin HEAD
Check for an existing PR first: Check for an existing PR first:
```bash ```bash
gh pr view --json url 2>/dev/null gh pr view --json number,url,title,body 2>/dev/null
``` ```
If none exists, create it. Pass the body via HEREDOC to preserve markdown formatting: ### 3a. No existing PR → create one
Pass the body via HEREDOC to preserve markdown formatting:
```bash ```bash
gh pr create --title "short, scope-prefixed title" --body "$(cat <<'EOF' gh pr create --title "short, scope-prefixed title" --body "$(cat <<'EOF'
@@ -85,7 +87,40 @@ EOF
Keep the title under ~70 characters. Use a scope prefix when there's an obvious one (`viewer:`, `core:`, `editor:`, `mcp:`). Keep the title under ~70 characters. Use a scope prefix when there's an obvious one (`viewer:`, `core:`, `editor:`, `mcp:`).
If a PR already exists, print its URL and stop — don't recreate. ### 3b. PR already exists → update its description
Do **not** recreate the PR. Refresh the existing body so it reflects everything currently on the branch, while keeping the template structure and any reviewer-meaningful state the user already set.
1. Capture the existing body and the full branch history:
```bash
gh pr view --json number,body -q '.number, .body' > /tmp/existing-pr.txt
git log --oneline main..HEAD
git diff --stat main..HEAD
```
2. Reconstruct the body section-by-section. Keep the four template headings in the same order (`## What does this PR do?`, `## How to test`, `## Screenshots / screen recording`, `## Checklist`). For each section:
- **What does this PR do?** — rewrite from the *current* commits and diff on the branch, not from memory. Preserve any `Fixes #123` / `Refs #123` lines from the old body.
- **How to test** — regenerate concrete steps for the *current* behaviour. If a previous step is still valid, keep its wording; drop steps that no longer apply; add steps for new commits.
- **Screenshots / screen recording** — preserve the existing content verbatim (links, embedded images, "N/A — …"). Do not blank it out. Only change it if the user explicitly provided a new recording.
- **Checklist** — preserve the user's tick state (`[x]` vs `[ ]`) for every item that still exists in the template. Add any new template items as unchecked.
If the old body contains extra sections the template doesn't have (e.g. a manual "Notes" block), keep them at the end.
3. Apply the update:
```bash
gh pr edit <number> --body "$(cat <<'EOF'
## What does this PR do?
EOF
)"
```
Use `gh pr edit --title` *only* if the branch's scope has clearly changed; otherwise leave the title alone.
4. Print the PR URL so the user can confirm the edit.
## 4. Report ## 4. Report
+8 -1
View File
@@ -9,8 +9,15 @@
// `loaded` guard inside `../lib/bootstrap` keeps the side effect // `loaded` guard inside `../lib/bootstrap` keeps the side effect
// idempotent under HMR. // idempotent under HMR.
import '../lib/bootstrap' import '../lib/bootstrap'
import type { ReactNode } from 'react' import { type ReactNode, useEffect } from 'react'
export function ClientBootstrap({ children }: { children: ReactNode }) { export function ClientBootstrap({ children }: { children: ReactNode }) {
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return
// Loaded here (not via a `<Script>` tag in <head>) to avoid React's
// "script inside a React component" hydration warning. The package
// is already a direct dep, so we don't need the CDN auto-global.
import('react-scan').then(({ scan }) => scan({ enabled: true }))
}, [])
return children return children
} }
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import './.next/types/routes.d.ts' import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -28,6 +28,40 @@ export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): s
return 'default' // fallback for orphaned items 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 // Call this once at app initialization. Returns an unsubscribe function that
// detaches the scene-store listener (useful when the editor is unmounted so // detaches the scene-store listener (useful when the editor is unmounted so
// the spatial grid singleton does not hold stale references to old scenes). // 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 { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
export { export {
initSpatialGridSync, initSpatialGridSync,
resolveBuildingForLevel,
resolveLevelId, resolveLevelId,
} from './hooks/spatial-grid/spatial-grid-sync' } from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
@@ -53,11 +54,13 @@ export {
} from './lib/door-operation' } from './lib/door-operation'
export { getRenderableSlabPolygon } from './lib/slab-polygon' export { getRenderableSlabPolygon } from './lib/slab-polygon'
export { export {
type AutoCeilingSyncPlan,
type AutoSlabSyncPlan, type AutoSlabSyncPlan,
detectSpacesForLevel, detectSpacesForLevel,
initSpaceDetectionSync, initSpaceDetectionSync,
isSpaceDetectionPaused, isSpaceDetectionPaused,
pauseSpaceDetection, pauseSpaceDetection,
planAutoCeilingsForLevel,
planAutoSlabsForLevel, planAutoSlabsForLevel,
resumeSpaceDetection, resumeSpaceDetection,
type Space, type Space,
@@ -100,6 +103,7 @@ export {
export { export {
default as useLiveNodeOverrides, default as useLiveNodeOverrides,
type LiveNodeOverrides, type LiveNodeOverrides,
getEffectiveNode,
} from './store/use-live-node-overrides' } from './store/use-live-node-overrides'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene' export { clearSceneHistory, default as useScene } from './store/use-scene'
@@ -172,11 +176,14 @@ export {
} from './systems/wall/wall-mitering' } from './systems/wall/wall-mitering'
export { export {
constrainWallMoveDeltaToAxis, constrainWallMoveDeltaToAxis,
getLinkedWallUpdates,
getPerpendicularWallMoveAxis, getPerpendicularWallMoveAxis,
getPlannedLinkedWallUpdates,
planWallMoveJunctions, planWallMoveJunctions,
type WallMoveAxis, type WallMoveAxis,
type WallMoveBridgePlan, type WallMoveBridgePlan,
type WallMoveJunctionPlan, type WallMoveJunctionPlan,
type WallMoveLinkedWallTargetPlan,
type WallPlanPoint, type WallPlanPoint,
} from './systems/wall/wall-move' } from './systems/wall/wall-move'
export type { SceneGraph } from './utils/clone-scene-graph' export type { SceneGraph } from './utils/clone-scene-graph'
+29 -10
View File
@@ -47,6 +47,12 @@ export type AutoSlabSyncPlan = {
delete: Array<SlabNodeType['id']> 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_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const ROOM_CURVE_TOLERANCE = 0.04 const ROOM_CURVE_TOLERANCE = 0.04
@@ -650,12 +656,10 @@ function syncAutoSlabsForLevel(
} }
} }
function syncAutoCeilingsForLevel( export function planAutoCeilingsForLevel(
levelId: string,
roomPolygons: Point2D[][], roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[], existingCeilings: CeilingNodeType[],
sceneStore: any, ): AutoCeilingSyncPlan {
) {
const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls) const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls)
const manualSignatures = new Set( const manualSignatures = new Set(
manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))), manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))),
@@ -782,16 +786,31 @@ function syncAutoCeilingsForLevel(
) )
} }
if (ceilingsToDelete.length > 0) { return {
sceneStore.getState().deleteNodes(ceilingsToDelete) 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) { if (plan.update.length > 0) {
sceneStore.getState().updateNodes(ceilingsToUpdate) sceneStore.getState().updateNodes(plan.update)
} }
if (ceilingsToCreate.length > 0) { if (plan.create.length > 0) {
sceneStore.getState().createNodes(ceilingsToCreate.map((node) => ({ node, parentId: levelId }))) sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
} }
} }
@@ -101,6 +101,7 @@ function buildFixture() {
function makeScene(nodes: Record<string, AnyNode>): SceneApi { function makeScene(nodes: Record<string, AnyNode>): SceneApi {
return { return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'], get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
update: () => {}, update: () => {},
upsert: () => ID(''), upsert: () => ID(''),
delete: () => {}, 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 { export {
discoverPlugins, discoverPlugins,
getSelectableKinds, getSelectableKinds,
isRegistryMovable,
isRegistrySelectable, isRegistrySelectable,
kindsWithFloorplanScope,
loadPlugin, loadPlugin,
nodeRegistry, nodeRegistry,
type PluginDiscovery, type PluginDiscovery,
@@ -15,6 +17,21 @@ export {
collectDescendants, collectDescendants,
type SpatialQuery, type SpatialQuery,
} from './relations-resolver' } 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 { createSceneApi, type SceneStoreLike } from './scene-api'
export type { export type {
Affordance, Affordance,
+64 -3
View File
@@ -3,6 +3,23 @@ import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types'
const HOST_API_VERSION = 1 as const 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 { class NodeRegistryImpl implements NodeRegistry {
private readonly defs = new Map<string, AnyNodeDefinition>() private readonly defs = new Map<string, AnyNodeDefinition>()
@@ -28,9 +45,6 @@ class NodeRegistryImpl implements NodeRegistry {
// Internal — exposed via registerNode below. // Internal — exposed via registerNode below.
_register(def: AnyNodeDefinition): void { _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) { if (typeof def.kind !== 'string' || def.kind.length === 0) {
throw new Error('[registry] NodeDefinition.kind must be a non-empty string') 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}")`, `[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) this.defs.set(def.kind, def)
} }
@@ -85,6 +114,38 @@ export function isRegistrySelectable(kind: string): boolean {
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined 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> { export async function loadPlugin(plugin: Plugin): Promise<void> {
if (plugin.apiVersion !== HOST_API_VERSION) { if (plugin.apiVersion !== HOST_API_VERSION) {
throw new Error( throw new Error(
@@ -38,6 +38,7 @@ function makeNode(kind: string, idStr: string, extra: Partial<AnyNode> = {}): An
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi { function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
return { return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'], get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
update: () => {}, update: () => {},
upsert: () => id(''), upsert: () => id(''),
delete: () => {}, delete: () => {},
+4
View File
@@ -50,6 +50,10 @@ export function createSceneApi(store: SceneStoreLike): SceneApi {
return store.getState().nodes[id] as N | undefined return store.getState().nodes[id] as N | undefined
}, },
nodes() {
return store.getState().nodes
},
update(id, patch) { update(id, patch) {
captureIfNeeded(id) captureIfNeeded(id)
store.getState().updateNode(id, patch) 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 { ZodObject, z } from 'zod'
import type { MaterialSchema } from '../schema/material' import type { MaterialSchema } from '../schema/material'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import type { HandleList } from './handles'
// ─── GeometryContext ───────────────────────────────────────────────── // ─── GeometryContext ─────────────────────────────────────────────────
// //
@@ -146,6 +147,24 @@ export type FloorplanStyle = {
strokeLinejoin?: 'miter' | 'round' | 'bevel' strokeLinejoin?: 'miter' | 'round' | 'bevel'
strokeOpacity?: number strokeOpacity?: number
fillOpacity?: 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 ──────────────────────────────────────────────────────── // ─── ToolHint ────────────────────────────────────────────────────────
@@ -272,6 +291,12 @@ export type FloorplanGeometry =
/** Stroke width in screen pixels — converted to plan units by the dispatcher. */ /** Stroke width in screen pixels — converted to plan units by the dispatcher. */
strokeWidthPx: number strokeWidthPx: number
cursor?: string 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 * Endpoint manipulation handle — the 5-circle stack from the legacy
@@ -341,6 +366,50 @@ export type FloorplanGeometry =
kind: 'move-handle' kind: 'move-handle'
point: FloorplanPoint 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 * Centered length / distance label. Renders as a small rounded
* background plate with text, oriented along `angle` (radians). The * 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. */ /** Node IDs the drag may mutate. Used by the dispatcher for the snapshot. */
affectedIds: AnyNodeId[] affectedIds: AnyNodeId[]
/** /**
* Run a single drag tick. Implementations call `scene.updateNodes` to * Run a single drag tick. Two patterns are supported:
* preview the next position. Snap logic, linked-node cascade, and * - **Scene-write preview**: implementation calls `scene.updateNodes`
* angle locking live here. * 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: { apply(args: {
planPoint: FloorplanAffordancePoint planPoint: FloorplanAffordancePoint
modifiers: FloorplanAffordanceModifiers modifiers: FloorplanAffordanceModifiers
}): void }): void
/** /**
* Called on pointer-up. Return `true` if the scene's current state * Called on pointer-up. Return `true` if the drag should commit;
* should be committed; `false` reverts to the snapshot (e.g. wall too * `false` reverts to the snapshot (e.g. wall too short, vertex
* short, vertex collapsed onto neighbour). * collapsed onto neighbour).
*/ */
canCommit(): boolean 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> = { export type FloorplanAffordance<N> = {
@@ -493,6 +582,23 @@ export type FloorplanMoveTargetSession = {
* area, overlap detected, ...). * area, overlap detected, ...).
*/ */
canCommit(): boolean 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: { export type FloorplanMoveTarget<N> = (args: {
@@ -592,6 +698,18 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* the legacy `floorplan-panel.tsx` monolith. * the legacy `floorplan-panel.tsx` monolith.
*/ */
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null 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 * 2D drag affordances keyed by the string identifier emitted on
* `endpoint-handle` (and similar interactive floor-plan primitives) via * `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. * unset and rely on the generic overlay path.
*/ */
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>> 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 system?: SystemContribution
tool?: LazyComponent tool?: LazyComponent
/** /**
@@ -679,6 +821,24 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* capability too). * capability too).
*/ */
keyboardActions?: KeyboardActions 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' export type NodeCategory = 'site' | 'structure' | 'furnish' | 'analysis' | 'utility'
@@ -790,6 +950,26 @@ export type Capabilities = {
floorPlaced?: FloorPlacedConfig floorPlaced?: FloorPlacedConfig
roofAccessory?: RoofAccessoryConfig roofAccessory?: RoofAccessoryConfig
paint?: PaintCapability 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 = { export type SceneApi = {
get: <N extends AnyNode = AnyNode>(id: AnyNodeId) => N | undefined 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 update: (id: AnyNodeId, patch: Partial<AnyNode>) => void
upsert: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId upsert: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId
delete: (id: AnyNodeId) => void delete: (id: AnyNodeId) => void
@@ -26,6 +26,7 @@ function makeSpyScene(initial: Record<string, AnyNode> = {}): SceneApi & {
const nodes = { ...initial } const nodes = { ...initial }
return { return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'], get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
update: (nid, patch) => { update: (nid, patch) => {
calls.updated.push([nid, patch]) calls.updated.push([nid, patch])
const existing = nodes[nid as string] 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 { function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
return { return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'], get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
update: () => {}, update: () => {},
upsert: () => id(''), upsert: () => id(''),
delete: () => {}, delete: () => {},
@@ -5,6 +5,7 @@ export type LiveNodeOverrides = Record<string, unknown>
type LiveNodeOverrideState = { type LiveNodeOverrideState = {
overrides: Map<string, LiveNodeOverrides> overrides: Map<string, LiveNodeOverrides>
set(nodeId: string, values: LiveNodeOverrides): void set(nodeId: string, values: LiveNodeOverrides): void
setMany(entries: ReadonlyArray<readonly [string, LiveNodeOverrides]>): void
get(nodeId: string): LiveNodeOverrides | undefined get(nodeId: string): LiveNodeOverrides | undefined
clear(nodeId: string): void clear(nodeId: string): void
clearAll(): void clearAll(): void
@@ -18,6 +19,19 @@ const useLiveNodeOverrides = create<LiveNodeOverrideState>((set, get) => ({
next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values }) next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values })
return { overrides: next } 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), get: (nodeId) => get().overrides.get(nodeId),
clear: (nodeId) => clear: (nodeId) =>
set((state) => { set((state) => {
@@ -28,4 +42,16 @@ const useLiveNodeOverrides = create<LiveNodeOverrideState>((set, get) => ({
clearAll: () => set({ overrides: new Map() }), 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 export default useLiveNodeOverrides
@@ -101,6 +101,88 @@ function getMoveWallRelation(
return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction' 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'>>( export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
linkedWalls: TWall[], linkedWalls: TWall[],
originalStart: WallPlanPoint, originalStart: WallPlanPoint,
@@ -23,7 +23,6 @@ type FloorplanCursorIndicator =
type FloorplanCursorIndicatorOverlayProps = { type FloorplanCursorIndicatorOverlayProps = {
cursorPosition: SvgPoint | null cursorPosition: SvgPoint | null
cursorAnchorPosition: SvgPoint | null
floorplanSelectionTool: FloorplanSelectionTool floorplanSelectionTool: FloorplanSelectionTool
movingOpeningType: 'door' | 'window' | null movingOpeningType: 'door' | 'window' | null
isPanning: boolean isPanning: boolean
@@ -35,7 +34,6 @@ type FloorplanCursorIndicatorOverlayProps = {
export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
cursorPosition, cursorPosition,
cursorAnchorPosition,
floorplanSelectionTool, floorplanSelectionTool,
movingOpeningType, movingOpeningType,
isPanning, isPanning,
@@ -81,7 +79,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
return null return null
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer]) }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition const position = cursorPosition
if (!(indicator && position) || isPanning) { if (!(indicator && position) || isPanning) {
return null return null
@@ -4,9 +4,12 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type CeilingNode, type CeilingNode,
getWallMidpointHandlePoint,
nodeRegistry, nodeRegistry,
type SlabNode, type SlabNode,
useLiveNodeOverrides,
useScene, useScene,
type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -29,6 +32,8 @@ import { NodeActionMenu } from '../editor/node-action-menu'
* `capabilities.movable`, `def.floorplanMoveTarget`, OR * `capabilities.movable`, `def.floorplanMoveTarget`, OR
* `def.affordanceTools.move` (slab / ceiling). The * `def.affordanceTools.move` (slab / ceiling). The
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path. * `<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 * - Add hole (slab + ceiling only): inserts a small default-square
* hole at the polygon centroid via `updateNode`. Mirrors the legacy * hole at the polygon centroid via `updateNode`. Mirrors the legacy
* `handleAddHole` in `floating-action-menu.tsx`. * `handleAddHole` in `floating-action-menu.tsx`.
@@ -52,6 +57,7 @@ export function FloorplanRegistryActionMenu() {
const def = selectedKind ? nodeRegistry.get(selectedKind) : null const def = selectedKind ? nodeRegistry.get(selectedKind) : null
const isRegistryKind = !!def const isRegistryKind = !!def
const isVisible = isRegistryKind && !movingNode const isVisible = isRegistryKind && !movingNode
const isWall = selectedKind === 'wall'
useEffect(() => { useEffect(() => {
if (!(isVisible && selectedId)) { if (!(isVisible && selectedId)) {
@@ -60,21 +66,53 @@ export function FloorplanRegistryActionMenu() {
} }
let raf = 0 let raf = 0
const tick = () => { const tick = () => {
const el = document.querySelector( raf = requestAnimationFrame(tick)
`[data-floorplan-scene] [data-node-id="${selectedId}"]`, 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 ) as SVGGElement | null
if (el) { if (el) {
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
// Position centered horizontally, ~12px above the bounding box. setPosition({ left: rect.left + rect.width / 2, top: rect.top })
setPosition({ left: rect.left + rect.width / 2, top: rect.top - 12 })
} else { } else {
setPosition(null) setPosition(null)
} }
raf = requestAnimationFrame(tick)
} }
raf = requestAnimationFrame(tick) raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf) return () => cancelAnimationFrame(raf)
}, [isVisible, selectedId]) }, [isVisible, selectedId, isWall])
if (!(isVisible && selectedId && position && def)) return null if (!(isVisible && selectedId && position && def)) return null
@@ -84,9 +122,11 @@ export function FloorplanRegistryActionMenu() {
// Move button is enabled when any of: // Move button is enabled when any of:
// - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence) // - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
// - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item) // - `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 // 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 = const canMove =
!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move !!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
const canDuplicate = def.capabilities.duplicable !== false const canDuplicate = def.capabilities.duplicable !== false
@@ -172,7 +212,7 @@ export function FloorplanRegistryActionMenu() {
style={{ style={{
left: position.left, left: position.left,
top: position.top, top: position.top,
transform: 'translate(-50%, -100%)', transform: 'translate(-50%, calc(-100% - 32px))',
}} }}
> >
<NodeActionMenu <NodeActionMenu
@@ -8,6 +8,7 @@ import {
pauseSceneHistory, pauseSceneHistory,
resumeSceneHistory, resumeSceneHistory,
snapPointToGrid, snapPointToGrid,
useLiveNodeOverrides,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -15,6 +16,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
const GRID_STEP = 0.5 const GRID_STEP = 0.5
@@ -41,6 +43,7 @@ const GRID_STEP = 0.5
export function FloorplanRegistryMoveOverlay() { export function FloorplanRegistryMoveOverlay() {
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
const def = movingNode ? nodeRegistry.get(movingNode.type) : null const def = movingNode ? nodeRegistry.get(movingNode.type) : null
const isActive = !!movingNode && !!def?.floorplan const isActive = !!movingNode && !!def?.floorplan
@@ -140,6 +143,30 @@ export function FloorplanRegistryMoveOverlay() {
const commitFinalStateOrRevert = () => { const commitFinalStateOrRevert = () => {
const commitValid = session.canCommit() 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 sceneState = useScene.getState().nodes
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = [] const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
for (const snap of snapshots) { for (const snap of snapshots) {
@@ -209,26 +236,21 @@ export function FloorplanRegistryMoveOverlay() {
// inside the SVG viewport, including empty grid background. // inside the SVG viewport, including empty grid background.
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
// Apply once more at the pointer-up coords before committing. // Commit using the LAST pointermove's state — no re-apply at
// Browsers don't guarantee a pointermove fires right before // pointer-up coords. A previous version re-applied here to
// pointerup — a quick click after a drag can land pointerup a // close a sub-pixel "drift" window when pointer-up fires
// few pixels past the last pointermove. Without this re-apply, // without a preceding pointermove, but that re-apply also
// the commit would freeze the item at the stale pointermove // re-snaps: if the pointer-up coord crosses a grid boundary
// position, leaving a visible drift between where the user // relative to the last pointermove, the snapped result flips
// released the click and where the item lands. // to a different grid cell and the wall (or other moved node)
const finalPlanPoint = toMeters(event.clientX, event.clientY) // visibly jumps from where it was painted during the drag to
if (finalPlanPoint) { // a different commit position. Trusting the last pointermove
hasMovedSinceStart = true // means "what you saw is what gets committed", which is the
session.apply({ // UX users expect — at the cost of a sub-pixel drift in the
planPoint: finalPlanPoint, // rare case where the OS fires pointerup with no preceding
modifiers: { // pointermove. Modern browsers reliably emit a final
shiftKey: event.shiftKey, // pointermove right before pointerup, so the trade-off lands
altKey: event.altKey, // on the side of WYSIWYG.
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
},
})
}
commitFinalStateOrRevert() commitFinalStateOrRevert()
setMovingNode(null) setMovingNode(null)
@@ -263,18 +285,27 @@ export function FloorplanRegistryMoveOverlay() {
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return 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. // Revert untracked, then resume — no history entry.
useScene.getState().updateNodes(snapshotsToUpdates(snapshots)) useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) { if (historyPaused) {
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
historyPaused = false historyPaused = false
} }
// Clear any live-transform previews the session wrote (slab / // Clear any live previews the session wrote. Slab / ceiling
// ceiling 2D move stages a translation delta in // 2D move stages a translation delta in `useLiveTransforms`;
// `useLiveTransforms`; without this clear, escape leaves the // wall move publishes `{ start, end, ... }` to
// 2D layer rendering the polygon at the cancelled delta). // `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) { 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. // Restore selection cleared by the action menu's Move click.
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) }) useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
@@ -288,56 +319,48 @@ export function FloorplanRegistryMoveOverlay() {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey) 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 // If `movingNodeOrigin === '3d'`, a 3D move tool finalised
// never committed — likely a mid-drag unmount. Revert the // while our overlay was still mounted (split view); the live
// untracked writes so we don't leak partial state. // scene IS the committed state and reverting would stomp it.
// - No 2D apply happened. The legacy `MoveItemContent` (3D // Otherwise (origin is `null` or `'2d'`) we own the teardown
// mover) may have committed via `draftNode.commit` just // and revert any untracked apply() writes back to baseline.
// 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.
// //
// Additionally, in split view the user may have brushed the // The two prior scenarios this block guarded against:
// cursor over the floor plan (setting `hasMovedSinceStart`) // - mid-drag unmount with apply() writes still present
// and then committed via a 3D mover. The 3D commit writes the // - 3D mover committing via `draftNode.commit` just before
// new state to `scene` directly, so by the time this cleanup // our unmount
// runs `snapshots` no longer matches scene state. Reverting // are now distinguished by the origin flag — no scene-state
// here would stomp the 3D commit. Detect the case by // diff heuristic required.
// 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.
if (historyPaused) { if (historyPaused) {
if (hasMovedSinceStart) { if (hasMovedSinceStart) {
const currentNodes = useScene.getState().nodes const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
const externallyCommitted = snapshots.some((snap) => { if (!finalisedBy3D) {
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) {
useScene.getState().updateNodes(snapshotsToUpdates(snapshots)) useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
} }
} }
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
} }
// Belt-and-suspenders: clear any live-transform previews on // Belt-and-suspenders: clear any live previews on abnormal
// abnormal unmount paths too. Slab / ceiling sessions write // unmount paths too. Slab / ceiling sessions write to
// `useLiveTransforms` to drive the smooth drag visual; in pure // `useLiveTransforms`; wall sessions write to
// 2D view the 3D `MoveSlabTool` cleanup isn't there to clear // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
// it for us. // 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) { 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) window.removeEventListener('keydown', onKey)
entry.removeAttribute('transform') entry.removeAttribute('transform')
} }
}, [isActive, movingNode, setMovingNode, hasMoveTarget, def]) }, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
return null return null
} }
@@ -25,6 +25,13 @@ export type FloorplanRenderContextValue = {
palette: FloorplanPalette palette: FloorplanPalette
/** SVG `<pattern>` id mounted in `<defs>` by the legacy panel for selection hatch fills. */ /** SVG `<pattern>` id mounted in `<defs>` by the legacy panel for selection hatch fills. */
hatchPatternId: string 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) const FloorplanRenderContext = createContext<FloorplanRenderContextValue | null>(null)
@@ -34,10 +41,11 @@ export function FloorplanRenderProvider({
unitsPerPixel, unitsPerPixel,
palette, palette,
hatchPatternId, hatchPatternId,
sceneRotationDeg,
}: FloorplanRenderContextValue & { children: ReactNode }) { }: FloorplanRenderContextValue & { children: ReactNode }) {
const value = useMemo<FloorplanRenderContextValue>( const value = useMemo<FloorplanRenderContextValue>(
() => ({ unitsPerPixel, palette, hatchPatternId }), () => ({ unitsPerPixel, palette, hatchPatternId, sceneRotationDeg }),
[unitsPerPixel, palette, hatchPatternId], [unitsPerPixel, palette, hatchPatternId, sceneRotationDeg],
) )
return <FloorplanRenderContext.Provider value={value}>{children}</FloorplanRenderContext.Provider> 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 strokeOpacity?: number
opacity?: number opacity?: number
vectorEffect?: 'non-scaling-stroke' vectorEffect?: 'non-scaling-stroke'
pointerEvents?: string
cursor?: string
} }
return { return {
fill: s.fill ?? 'none', fill: s.fill ?? 'none',
@@ -58,6 +60,8 @@ function styleAttrs(g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['ki
strokeOpacity: s.strokeOpacity, strokeOpacity: s.strokeOpacity,
opacity: s.opacity, opacity: s.opacity,
vectorEffect: s.vectorEffect, vectorEffect: s.vectorEffect,
pointerEvents: s.pointerEvents,
style: s.cursor ? { cursor: s.cursor } : undefined,
} }
} }
@@ -8,8 +8,10 @@ import {
type FloorplanGeometry, type FloorplanGeometry,
type FloorplanPalette, type FloorplanPalette,
type GeometryContext, type GeometryContext,
kindsWithFloorplanScope,
nodeRegistry, nodeRegistry,
pauseSceneHistory, pauseSceneHistory,
resolveBuildingForLevel,
resumeSceneHistory, resumeSceneHistory,
useInteractive, useInteractive,
useLiveNodeOverrides, useLiveNodeOverrides,
@@ -101,16 +103,74 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
} }
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { 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 selectedIds = useViewer((s) => s.selection.selectedIds)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId) const hoveredId = useViewer((s) => s.hoveredId)
const setHoveredId = useViewer((s) => s.setHoveredId) const setHoveredId = useViewer((s) => s.setHoveredId)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes) 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 renderCtx = useFloorplanRender()
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
const setMovingNode = useEditor((s) => s.setMovingNode) 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 // Subscribe to the live-transforms map ref so the layer re-renders
// whenever a 3D mover publishes a per-frame position (see // whenever a 3D mover publishes a per-frame position (see
// `usePlacementCoordinator`). Without this the 2D floor plan only // `usePlacementCoordinator`). Without this the 2D floor plan only
@@ -140,6 +200,21 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (event.button !== 0) return if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
setSelection({ selectedIds: [id] }) 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], [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, selected,
highlighted, highlighted,
hovered, hovered,
@@ -256,6 +348,59 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
visit(levelId as AnyNodeId) 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 // Stable z-order sort. SVG renders in document order — later siblings
// paint on top of earlier ones — so anything that should sit *under* // paint on top of earlier ones — so anything that should sit *under*
// other floor-plan geometry has to come first in the entries array. // other floor-plan geometry has to come first in the entries array.
@@ -269,11 +414,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
levelId, levelId,
nodes, nodes,
liveTransforms, liveTransforms,
liveOverrides,
selectedIdSet, selectedIdSet,
highlightedIdSet, highlightedIdSet,
hoveredId, hoveredId,
movingNode?.id, movingNode?.id,
renderCtx?.palette, renderCtx?.palette,
interactiveElevators,
]) ])
// ── Generic 2D affordance dispatch ───────────────────────────────── // ── Generic 2D affordance dispatch ─────────────────────────────────
@@ -362,6 +509,25 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const commitValid = drag.session.canCommit() 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 // Capture the final state BEFORE the revert so we know what to
// re-apply post-resume. // re-apply post-resume.
const sceneNodes = useScene.getState().nodes const sceneNodes = useScene.getState().nodes
@@ -395,12 +561,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
sfxEmitter.emit('sfx:structure-build') sfxEmitter.emit('sfx:structure-build')
} else { } else {
// Either no net change or canCommit() rejected — revert and // 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)) useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
if (drag.historyPaused) { if (drag.historyPaused) {
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
drag.historyPaused = false drag.historyPaused = false
} }
const overrides = useLiveNodeOverrides.getState()
for (const id of drag.session.affectedIds) overrides.clear(id)
} }
dragRef.current = null dragRef.current = null
@@ -417,6 +587,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
drag.historyPaused = false 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 dragRef.current = null
setActiveDragId(null) setActiveDragId(null)
@@ -430,13 +606,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerCancel) window.removeEventListener('pointercancel', onPointerCancel)
// Component unmounted mid-drag — restore the baseline and unpause // 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 const drag = dragRef.current
if (drag) { if (drag) {
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots)) useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
if (drag.historyPaused) { if (drag.historyPaused) {
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
} }
const overrides = useLiveNodeOverrides.getState()
for (const id of drag.session.affectedIds) overrides.clear(id)
dragRef.current = null dragRef.current = null
} }
} }
@@ -452,8 +632,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
className="floorplan-registry-entry" className="floorplan-registry-entry"
data-node-id={id} data-node-id={id}
key={key} key={key}
onClick={handleClickStop} onClick={isOpeningPlacementActive ? undefined : handleClickStop}
onPointerDown={(e) => handleSelect(id, e)} onPointerDown={isOpeningPlacementActive ? undefined : (e) => handleSelect(id, e)}
// Mirror the sidebar tree nodes' hover wiring — `useViewer. // Mirror the sidebar tree nodes' hover wiring — `useViewer.
// hoveredId` drives the highlight halo in 3D as well as the // hoveredId` drives the highlight halo in 3D as well as the
// wall / fence floor-plan hover stroke. Setting it on // wall / fence floor-plan hover stroke. Setting it on
@@ -490,6 +670,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
setMovingNode(node as never) setMovingNode(node as never)
}} }}
palette={palette} palette={palette}
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
unitsPerPixel={unitsPerPixel} unitsPerPixel={unitsPerPixel}
/> />
</g> </g>
@@ -510,7 +691,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// appear to "deselect themselves a fraction of a second after // appear to "deselect themselves a fraction of a second after
// clicking." Scoped to `onClick` so hover / drag / pointer events // clicking." Scoped to `onClick` so hover / drag / pointer events
// still propagate normally inside the registry tree. // 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, {/* Base pass — rank-sorted body geometry (polygons, paths, fills,
strokes, hatches). Lower-rank kinds (zones) paint first so strokes, hatches). Lower-rank kinds (zones) paint first so
higher-rank kinds (slabs, then walls / items / shelves) layer higher-rank kinds (slabs, then walls / items / shelves) layer
@@ -544,6 +730,7 @@ function InteractiveGeometry({
hoveredHandleId, hoveredHandleId,
activeDragId, activeDragId,
nodeId, nodeId,
sceneRotationDeg,
onHandleHoverChange, onHandleHoverChange,
onHandlePointerDown, onHandlePointerDown,
onMoveHandlePointerDown, onMoveHandlePointerDown,
@@ -555,6 +742,7 @@ function InteractiveGeometry({
hoveredHandleId: string | null hoveredHandleId: string | null
activeDragId: string | null activeDragId: string | null
nodeId: AnyNodeId nodeId: AnyNodeId
sceneRotationDeg: number
onHandleHoverChange: (id: string | null) => void onHandleHoverChange: (id: string | null) => void
onHandlePointerDown: ( onHandlePointerDown: (
affordance: string, affordance: string,
@@ -591,7 +779,7 @@ function InteractiveGeometry({
return ( return (
<line <line
key={keyHint} key={keyHint}
pointerEvents="stroke" pointerEvents={g.pointerEvents ?? 'stroke'}
stroke="transparent" stroke="transparent"
strokeLinecap="round" strokeLinecap="round"
strokeWidth={g.strokeWidthPx * unitsPerPixel} strokeWidth={g.strokeWidthPx * unitsPerPixel}
@@ -705,15 +893,19 @@ function InteractiveGeometry({
if (!palette) return <></> if (!palette) return <></>
const moveHandleId = `${nodeId}:move` const moveHandleId = `${nodeId}:move`
const isHovered = hoveredHandleId === moveHandleId const isHovered = hoveredHandleId === moveHandleId
// Move dots are visually bigger than endpoint handles — the // World-relative sizing: the move dot is anchored to the door,
// legacy prod render uses ~13px outer / ~6px dot. Endpoint // not to the screen, so it grows when the user zooms in and
// handles top out at 8/9px because there are usually two per // shrinks when they zoom out — same scaling rule as the door
// wall + linked walls + curve handle nearby; the move dot is // footprint itself. Sizes are tuned for a ~0.9 m door at default
// a singleton centerpiece so it can afford the extra weight. // zoom; the ratios match the legacy 13/15/6/16/7/18 px stack.
const baseRadiusPx = 13 const baseRadius = 0.1
const hoverRadiusPx = 15 const hoverRadius = 0.115
const outerRadius = (isHovered ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel const outerRadius = isHovered ? hoverRadius : baseRadius
const dotRadius = 6 * unitsPerPixel 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 + // Same 5-circle stack as the orange endpoint dot — hover glow +
// hover ring + filled outer + inner dot + transparent hit. On // hover ring + filled outer + inner dot + transparent hit. On
// pointer-down, the layer calls `setMovingNode(node)`, which // pointer-down, the layer calls `setMovingNode(node)`, which
@@ -734,9 +926,8 @@ function InteractiveGeometry({
r={outerRadius} r={outerRadius}
stroke={palette.endpointHandleHoverStroke} stroke={palette.endpointHandleHoverStroke}
strokeOpacity={0.16} strokeOpacity={0.16}
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * unitsPerPixel} strokeWidth={glowStroke}
style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }} style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
vectorEffect="non-scaling-stroke"
/> />
<circle <circle
cx={g.point[0]} cx={g.point[0]}
@@ -746,9 +937,8 @@ function InteractiveGeometry({
r={outerRadius} r={outerRadius}
stroke={palette.endpointHandleHoverStroke} stroke={palette.endpointHandleHoverStroke}
strokeOpacity={0.52} strokeOpacity={0.52}
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel} strokeWidth={ringStroke}
style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }} style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
vectorEffect="non-scaling-stroke"
/> />
<circle <circle
cx={g.point[0]} cx={g.point[0]}
@@ -758,8 +948,7 @@ function InteractiveGeometry({
pointerEvents="none" pointerEvents="none"
r={outerRadius} r={outerRadius}
stroke={palette.endpointHandleStroke} stroke={palette.endpointHandleStroke}
strokeWidth="0.05" strokeWidth={fillStroke}
vectorEffect="non-scaling-stroke"
/> />
<circle <circle
cx={g.point[0]} cx={g.point[0]}
@@ -767,7 +956,6 @@ function InteractiveGeometry({
fill={palette.endpointHandleStroke} fill={palette.endpointHandleStroke}
pointerEvents="none" pointerEvents="none"
r={dotRadius} r={dotRadius}
vectorEffect="non-scaling-stroke"
/> />
<circle <circle
cx={g.point[0]} cx={g.point[0]}
@@ -777,10 +965,156 @@ function InteractiveGeometry({
pointerEvents="all" pointerEvents="all"
r={outerRadius} r={outerRadius}
stroke="transparent" stroke="transparent"
strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel} strokeWidth={hitStroke}
style={{ cursor: 'move' }} 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" 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> </g>
) )
} }
@@ -937,11 +1271,19 @@ function InteractiveGeometry({
} }
case 'dimension-label': { case 'dimension-label': {
if (!palette) return <></> if (!palette) return <></>
// Flip the label upright if it would otherwise be upside-down // Flip the label upright relative to the SCREEN, not the local
// (legacy floorplan-panel.tsx does the same — see line ~2548). // 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 let degrees = (g.angle * 180) / Math.PI
if (degrees > 90) degrees -= 180 let screenDegrees = degrees + sceneRotationDeg
else if (degrees <= -90) degrees += 180 screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
if (screenDegrees > 90) degrees -= 180
else if (screenDegrees <= -90) degrees += 180
const padX = unitsPerPixel * 6 const padX = unitsPerPixel * 6
const padY = unitsPerPixel * 3 const padY = unitsPerPixel * 3
@@ -1220,6 +1562,8 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
'midpoint-handle', 'midpoint-handle',
'edge-handle', 'edge-handle',
'move-handle', 'move-handle',
'move-arrow',
'rotate-arrow',
'dimension', 'dimension',
'dimension-label', 'dimension-label',
]) ])
@@ -139,9 +139,15 @@ export const CustomCameraControls = () => {
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee' const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
const isInteracting = Boolean( const isInteracting = Boolean(
tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive, tool ||
movingNode ||
movingWallEndpoint ||
movingFenceEndpoint ||
activeHandleDrag ||
isBoxSelectActive,
) )
const touches = useMemo(() => { const touches = useMemo(() => {
const twoFingerAction = const twoFingerAction =
@@ -10,6 +10,7 @@ import {
FenceNode, FenceNode,
generateId, generateId,
ItemNode, ItemNode,
isRegistryMovable,
isRegistrySelectable, isRegistrySelectable,
nodeRegistry, nodeRegistry,
RoofSegmentNode, RoofSegmentNode,
@@ -25,8 +26,7 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { Move } from 'lucide-react' import { useCallback, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
@@ -53,6 +53,60 @@ const ALLOWED_TYPES = [
const DELETE_ONLY_TYPES: string[] = [] const DELETE_ONLY_TYPES: string[] = []
const HOLE_TYPES = ['slab', 'ceiling'] 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() { export function FloatingActionMenu() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
@@ -62,17 +116,13 @@ export function FloatingActionMenu() {
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const curvingFence = useEditor((s) => s.curvingFence) const curvingFence = useEditor((s) => s.curvingFence)
const setMovingNode = useEditor((s) => s.setMovingNode) 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 setCurvingWall = useEditor((s) => s.setCurvingWall)
const setCurvingFence = useEditor((s) => s.setCurvingFence) const setCurvingFence = useEditor((s) => s.setCurvingFence)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
const groupRef = useRef<THREE.Group>(null) const groupRef = useRef<THREE.Group>(null)
const startEndpointGroupRef = useRef<THREE.Group>(null) const menuScaleRef = useRef<HTMLDivElement>(null)
const endEndpointGroupRef = useRef<THREE.Group>(null)
const [altPressed, setAltPressed] = useState(false)
// Only show for single selection of specific types // Only show for single selection of specific types
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
@@ -104,116 +154,37 @@ export function FloatingActionMenu() {
}) })
}) })
useEffect(() => { useFrame((state) => {
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(() => {
if (!(selectedId && isValidType && groupRef.current)) return 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) const obj = sceneRegistry.nodes.get(selectedId)
if (obj) { if (obj) {
// Calculate bounding box in world space // Calculate bounding box in world space
const box = new THREE.Box3().setFromObject(obj) const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) { if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3())
// Position above the object, with extra offset for walls/slabs to avoid covering measurement labels // Position above the object. Per-type offsets clear each kind's
const isStructural = node && [...DELETE_ONLY_TYPES, ...HOLE_TYPES].includes(node.type) // in-world chrome (height-resize arrows, measurement labels).
const yOffset = isStructural ? 0.8 : 0.3 groupRef.current.position.set(center.x, box.max.y + getMenuYOffset(node), center.z)
groupRef.current.position.set(center.x, box.max.y + yOffset, 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( const handleCurve = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
@@ -231,23 +202,16 @@ export function FloatingActionMenu() {
}, },
[canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection], [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
) )
const handleEndpointMove = useCallback( const handleMove = useCallback(
(endpoint: 'start' | 'end', e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
if (node.type === 'wall') { setMovingNode(node as any)
setMovingWallEndpoint({ wall: node, endpoint })
} else if (node.type === 'fence') {
setMovingFenceEndpoint({ fence: node, endpoint })
} else {
return
}
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, },
[node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection], [node, setMovingNode, setSelection],
) )
const handleDuplicate = useCallback( const handleDuplicate = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
@@ -474,91 +438,38 @@ export function FloatingActionMenu() {
}} }}
zIndexRange={[100, 0]} zIndexRange={[100, 0]}
> >
<NodeActionMenu <div ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined} <NodeActionMenu
onCurve={ onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall) onCurve={
? handleCurve node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
: undefined ? handleCurve
} : undefined
onDelete={handleDelete} }
onDuplicate={ onMove={
node && // Registry-driven: any kind that declares
node.type !== 'spawn' && // `capabilities.movable`, a `floorplanMoveTarget`, or a
!DELETE_ONLY_TYPES.includes(node.type) && // 3D `affordanceTools.move` mover gets the Move button.
!HOLE_TYPES.includes(node.type) // Replaces the previous 13-arm `node?.type === '…'`
? handleDuplicate // chain so adding a new movable kind doesn't touch this
: undefined // file.
} node && isRegistryMovable(node.type) ? handleMove : undefined
onMove={ }
node && onDelete={handleDelete}
node.type !== 'wall' && onDuplicate={
node.type !== 'fence' && node &&
!DELETE_ONLY_TYPES.includes(node.type) node.type !== 'spawn' &&
? handleMove !DELETE_ONLY_TYPES.includes(node.type) &&
: undefined !HOLE_TYPES.includes(node.type)
} ? handleDuplicate
onPointerDown={(e) => e.stopPropagation()} : undefined
onPointerUp={(e) => e.stopPropagation()} }
/> onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
/>
</div>
</Html> </Html>
</group> </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> </group>
) )
} }
@@ -53,6 +53,7 @@ import {
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -80,6 +81,7 @@ import {
type FloorplanRenderContextValue, type FloorplanRenderContextValue,
FloorplanRenderProvider, FloorplanRenderProvider,
} from '../editor-2d/floorplan-render-context' } 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 { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer' import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-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 { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { snapToHalf } from '../tools/item/placement-math' import { snapToHalf } from '../tools/item/placement-math'
import {
formatAngleRadians,
getAngleArcToSegmentReference,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../tools/shared/segment-angle'
import { import {
DEFAULT_STAIR_ATTACHMENT_SIDE, DEFAULT_STAIR_ATTACHMENT_SIDE,
DEFAULT_STAIR_FILL_TO_FLOOR, DEFAULT_STAIR_FILL_TO_FLOOR,
@@ -98,8 +106,9 @@ import {
} from '../tools/stair/stair-defaults' } from '../tools/stair/stair-defaults'
import { import {
createWallOnCurrentLevel, createWallOnCurrentLevel,
isWallLongEnough, isSegmentLongEnough,
snapWallDraftPoint, snapWallDraftPoint,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP, WALL_GRID_STEP,
type WallPlanPoint, type WallPlanPoint,
} from '../tools/wall/wall-drafting' } 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 { function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean {
return a[0] === b[0] && a[1] === b[1] return a[0] === b[0] && a[1] === b[1]
} }
@@ -3875,6 +4046,7 @@ export function FloorplanPanel() {
const viewportHostRef = useRef<HTMLDivElement>(null) const viewportHostRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const floorplanSceneRef = useRef<SVGGElement>(null) const floorplanSceneRef = useRef<SVGGElement>(null)
const floorplanContentRef = useRef<SVGGElement>(null)
const panStateRef = useRef<PanState | null>(null) const panStateRef = useRef<PanState | null>(null)
const guideInteractionRef = useRef<GuideInteractionState | null>(null) const guideInteractionRef = useRef<GuideInteractionState | null>(null)
const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null) const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
@@ -3904,6 +4076,11 @@ export function FloorplanPanel() {
const selectedItem = useEditor((state) => state.selectedItem) const selectedItem = useEditor((state) => state.selectedItem)
const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered) 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 selectedReferenceId = useEditor((state) => state.selectedReferenceId)
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const setMode = useEditor((state) => state.setMode) const setMode = useEditor((state) => state.setMode)
@@ -3943,6 +4120,30 @@ export function FloorplanPanel() {
walls, walls,
zones, zones,
} = useFloorplanSceneData({ buildingId, levelId }) } = 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( const elevators = useScene(
useShallow((state) => { useShallow((state) => {
const building = currentBuildingId ? state.nodes[currentBuildingId] : null const building = currentBuildingId ? state.nodes[currentBuildingId] : null
@@ -4080,6 +4281,17 @@ export function FloorplanPanel() {
const [isPanelReady, setIsPanelReady] = useState(false) const [isPanelReady, setIsPanelReady] = useState(false)
const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 }) const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 })
const [viewport, setViewport] = useState<FloorplanViewport | null>(null) 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(() => { useEffect(() => {
if (structureLayer === 'zones' && floorplanSelectionTool === 'marquee') { if (structureLayer === 'zones' && floorplanSelectionTool === 'marquee') {
@@ -4820,7 +5032,7 @@ export function FloorplanPanel() {
}, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon]) }, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon])
const draftPolygon = useMemo(() => { const draftPolygon = useMemo(() => {
if (!(levelId && draftStart && draftEnd && isWallLongEnough(draftStart, draftEnd))) { if (!(levelId && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) {
return null 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. // Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.
return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA) return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
}, [draftEnd, draftStart, levelId]) }, [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(() => { const draftPolygonPoints = useMemo(() => {
if (isRoofBuildActive && roofDraftStart && roofDraftEnd) { if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
const minX = Math.min(roofDraftStart[0], roofDraftEnd[0]) const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
@@ -4920,7 +5200,11 @@ export function FloorplanPanel() {
const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1 const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1
const fittedViewport = useMemo(() => { 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 : []), ...(visibleSitePolygon ? visibleSitePolygon.polygon : []),
...displayCeilingPolygons.flatMap((entry) => entry.polygon), ...displayCeilingPolygons.flatMap((entry) => entry.polygon),
...displaySlabPolygons.flatMap((entry) => entry.polygon), ...displaySlabPolygons.flatMap((entry) => entry.polygon),
@@ -4935,27 +5219,46 @@ export function FloorplanPanel() {
...wallPolygons.flatMap((entry) => entry.polygon), ...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 minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY let maxY = Number.NEGATIVE_INFINITY
for (const point of allPoints) { for (const point of legacyPoints) {
const svgPoint = toSvgPoint(point) const svgPoint = rotateSvgPoint(toSvgPoint(point), floorplanSceneRotationDeg)
minX = Math.min(minX, svgPoint.x) minX = Math.min(minX, svgPoint.x)
maxX = Math.max(maxX, svgPoint.x) maxX = Math.max(maxX, svgPoint.x)
minY = Math.min(minY, svgPoint.y) minY = Math.min(minY, svgPoint.y)
maxY = Math.max(maxY, 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 rawWidth = maxX - minX
const rawHeight = maxY - minY const rawHeight = maxY - minY
const paddedWidth = rawWidth + FLOORPLAN_PADDING * 2 const paddedWidth = rawWidth + FLOORPLAN_PADDING * 2
@@ -4976,13 +5279,52 @@ export function FloorplanPanel() {
floorplanFenceEntries, floorplanFenceEntries,
floorplanItemEntries, floorplanItemEntries,
floorplanRoofEntries, floorplanRoofEntries,
floorplanSceneRotationDeg,
floorplanStairEntries, floorplanStairEntries,
measuredSceneBBox,
svgAspectRatio, svgAspectRatio,
visibleSitePolygon, visibleSitePolygon,
visibleZonePolygons, visibleZonePolygons,
wallPolygons, 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(() => { useEffect(() => {
const host = viewportHostRef.current const host = viewportHostRef.current
if (!host) { 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(() => { useEffect(() => {
const levelChanged = previousLevelIdRef.current !== (levelId ?? null) const levelChanged = previousLevelIdRef.current !== (levelId ?? null)
@@ -5108,32 +5463,6 @@ export function FloorplanPanel() {
() => Math.max(floorplanWorldUnitsPerPixel * 0.55, 0.0001), () => Math.max(floorplanWorldUnitsPerPixel * 0.55, 0.0001),
[floorplanWorldUnitsPerPixel], [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(() => { useEffect(() => {
setHoveredGuideCorner(null) setHoveredGuideCorner(null)
}, []) }, [])
@@ -5864,10 +6193,16 @@ export function FloorplanPanel() {
return return
} }
const svgPoint = getSvgPointFromClientPoint(clientX, clientY) // `getSvgPointFromClientPoint` resolves to the rotation group's
if (!svgPoint) { // 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 return
} }
const svgPoint = rotateSvgPoint(localPoint, floorplanSceneRotationDeg)
const currentViewport = viewport ?? fittedViewport const currentViewport = viewport ?? fittedViewport
const currentViewBox = viewBox const currentViewBox = viewBox
@@ -5889,6 +6224,7 @@ export function FloorplanPanel() {
}, },
[ [
fittedViewport, fittedViewport,
floorplanSceneRotationDeg,
getSvgPointFromClientPoint, getSvgPointFromClientPoint,
maxViewportWidth, maxViewportWidth,
minViewportWidth, minViewportWidth,
@@ -6347,12 +6683,14 @@ export function FloorplanPanel() {
return 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({ const snappedPoint = snapWallDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
start: dragState.fixedPoint,
angleSnap: !shiftPressed,
ignoreWallIds: [dragState.wallId], ignoreWallIds: [dragState.wallId],
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
}) })
if (pointsEqual(dragState.currentPoint, snappedPoint)) { 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( useScene.getState().updateNodes(
commitUpdates.map((update) => ({ commitUpdates.map((update) => ({
id: update.id as AnyNodeId, id: update.id as AnyNodeId,
@@ -6927,12 +7265,14 @@ export function FloorplanPanel() {
if (isFenceBuildActive) { if (isFenceBuildActive) {
emitFloorplanGridEvent('move', planPoint, event) 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({ const snappedPoint = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
fences, fences,
start: fenceDraftStart ?? undefined, step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
angleSnap: Boolean(fenceDraftStart) && !shiftPressed,
}) })
setCursorPoint((previousPoint) => setCursorPoint((previousPoint) =>
@@ -6973,19 +7313,14 @@ export function FloorplanPanel() {
return return
} }
// Wall build also needs to run before the catch-all — see the // Opening placement (door / window build, plus door / window move)
// wall branch in `handleBackgroundPlacementClick` for the same // must run before the registry catch-all: door & window are
// restructuring. The wall branch lives further below in this // registered kinds, so `isRegistryToolBuildActive` is true during
// handler (`if (!isWallBuildActive) ... setDraftEnd(...)`); the // their build mode and the catch-all would otherwise emit
// grid emit is inlined there. // `grid:move` and return — never emitting the `wall:enter` /
if (!isWallBuildActive && isFloorplanGridInteractionActive) { // `wall:move` events the door / window placement tools listen for.
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event) // Same reason `handleBackgroundPlacementClick` runs its opening
setCursorPoint((previousPoint) => // branch before its grid catch-all.
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
)
return
}
if (isOpeningPlacementActive) { if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls, { const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall), canUseWall: (wall) => !isCurvedWall(wall),
@@ -7020,6 +7355,19 @@ export function FloorplanPanel() {
return 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) { if (isMarqueeSelectionToolActive) {
setCursorPoint((previousPoint) => { setCursorPoint((previousPoint) => {
const snappedPoint = getSnappedFloorplanPoint(planPoint) const snappedPoint = getSnappedFloorplanPoint(planPoint)
@@ -7035,11 +7383,13 @@ export function FloorplanPanel() {
return 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({ const snappedPoint = snapWallDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
start: draftStart ?? undefined, step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
angleSnap: Boolean(draftStart) && !shiftPressed,
}) })
// Emit `grid:move` so the registry-driven wall tool's 3D preview // Emit `grid:move` so the registry-driven wall tool's 3D preview
@@ -7241,14 +7591,34 @@ export function FloorplanPanel() {
return return
} }
if (!isWallLongEnough(draftStart, point)) { if (!isSegmentLongEnough(draftStart, point)) {
return return
} }
createWallOnCurrentLevel(draftStart, point) // The 3D wall tool's `grid:click` listener
clearDraft() // (`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({ const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
ceilingPolygons: displayCeilingPolygons, ceilingPolygons: displayCeilingPolygons,
@@ -8224,7 +8594,6 @@ export function FloorplanPanel() {
<FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} /> <FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} />
<div className="relative min-h-0 flex-1" ref={viewportHostRef}> <div className="relative min-h-0 flex-1" ref={viewportHostRef}>
<Editor2dFloorplanCursorIndicatorOverlay <Editor2dFloorplanCursorIndicatorOverlay
cursorAnchorPosition={floorplanCursorAnchorPosition}
cursorColor={floorplanCursorColor} cursorColor={floorplanCursorColor}
cursorPosition={floorplanCursorPosition} cursorPosition={floorplanCursorPosition}
floorplanSelectionTool={floorplanSelectionTool} floorplanSelectionTool={floorplanSelectionTool}
@@ -8357,7 +8726,7 @@ export function FloorplanPanel() {
</form> </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"> <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. Switch to a building level to view and edit the floorplan.
</div> </div>
@@ -8542,9 +8911,23 @@ export function FloorplanPanel() {
<FloorplanRenderProvider <FloorplanRenderProvider
hatchPatternId={wallSelectionHatchId} hatchPatternId={wallSelectionHatchId}
palette={floorplanRegistryPalette} palette={floorplanRegistryPalette}
sceneRotationDeg={floorplanSceneRotationDeg}
unitsPerPixel={floorplanUnitsPerPixel} 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> </FloorplanRenderProvider>
{/* Cursor-driven placement ghost for movingNode when the {/* Cursor-driven placement ghost for movingNode when the
active kind is registry-driven. Renders via a portal active kind is registry-driven. Renders via a portal
@@ -8595,6 +8978,17 @@ export function FloorplanPanel() {
unitsPerPixel={floorplanUnitsPerPixel} 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 / {/* Wall / fence endpoint, wall curve, slab / ceiling /
zone vertex+midpoint+edge handles are all driven by the zone vertex+midpoint+edge handles are all driven by the
registry's `def.floorplanAffordances` and rendered as registry's `def.floorplanAffordances` and rendered as
+59 -11
View File
@@ -1,10 +1,10 @@
'use client' '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 { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react' 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 { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useGridEvents } from '../../hooks/use-grid-events' import { useGridEvents } from '../../hooks/use-grid-events'
@@ -118,10 +118,18 @@ export const Grid = ({
// Use custom raycasting for grid events (independent of mesh events) // Use custom raycasting for grid events (independent of mesh events)
useGridEvents(gridY) 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(() => { useEffect(() => {
const onGridMove = (event: GridEvent) => { 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) emitter.on('grid:move', onGridMove)
@@ -130,31 +138,71 @@ export const Grid = ({
} }
}, []) }, [])
const worldPosScratch = useMemo(() => new Vector3(), [])
useFrame((_, delta) => { 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 let targetY = 0
if (currentLevelId) { if (levelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId) const levelMesh = sceneRegistry.nodes.get(levelId)
if (levelMesh) { if (levelMesh) {
targetY = levelMesh.position.y 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 gridRef.current.position.y = newY
setGridY(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) 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 ( return (
<mesh <mesh
geometry={geometry}
layers={GRID_LAYER} layers={GRID_LAYER}
material={material} material={material}
ref={gridRef} ref={gridRef}
rotation-x={-Math.PI / 2} rotation-x={-Math.PI / 2}
visible={showGrid} 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 { FloorplanPanel } from './floorplan-panel'
import { Grid } from './grid' import { Grid } from './grid'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
import { NodeArrowHandles } from './node-arrow-handles'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels' import { SiteEdgeLabels } from './site-edge-labels'
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay' 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 ── // ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
const ViewerSceneContent = memo(function ViewerSceneContent({ const ViewerSceneContent = memo(function ViewerSceneContent({
@@ -588,6 +599,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
<> <>
{!isFirstPersonMode && <SelectionManager />} {!isFirstPersonMode && <SelectionManager />}
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />} {!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
{!(isVersionPreviewMode || isFirstPersonMode) && <NodeArrowHandles />}
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />} {!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />} {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />} {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
@@ -598,9 +610,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
<CeilingSelectionAffordanceSystem /> <CeilingSelectionAffordanceSystem />
<RoofEditSystem /> <RoofEditSystem />
<StairEditSystem /> <StairEditSystem />
{!(isLoading || isFirstPersonMode) && ( {!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
<Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
)}
{!(isLoading || isVersionPreviewMode || isFirstPersonMode) && <ToolManager />} {!(isLoading || isVersionPreviewMode || isFirstPersonMode) && <ToolManager />}
{isFirstPersonMode && <FirstPersonControls />} {isFirstPersonMode && <FirstPersonControls />}
<CustomCameraControls /> <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 { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { getPlanPointDistance } from '../../lib/floorplan' import { getPlanPointDistance } from '../../lib/floorplan'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' 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 = { type UseFloorplanBackgroundPlacementArgs = {
activePolygonDraftPoints: WallPlanPoint[] activePolygonDraftPoints: WallPlanPoint[]
@@ -53,7 +53,8 @@ type UseFloorplanBackgroundPlacementArgs = {
point: WallPlanPoint point: WallPlanPoint
walls: WallNode[] walls: WallNode[]
start?: WallPlanPoint start?: WallPlanPoint
angleSnap: boolean angleSnap?: boolean
step?: number
}) => WallPlanPoint }) => WallPlanPoint
snapPolygonDraftPoint: (args: { snapPolygonDraftPoint: (args: {
point: WallPlanPoint point: WallPlanPoint
@@ -156,12 +157,12 @@ export function useFloorplanBackgroundPlacement({
if (isFenceBuildActive) { if (isFenceBuildActive) {
emitFloorplanGridEvent('click', planPoint, event) emitFloorplanGridEvent('click', planPoint, event)
// Fence draft: grid snap only; Shift = fine step. See `wall/tool.tsx`.
const snappedPoint = snapFenceDraftPoint({ const snappedPoint = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
fences, fences,
start: fenceDraftStart ?? undefined, step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
angleSnap: Boolean(fenceDraftStart) && !shiftPressed,
}) })
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
@@ -212,11 +213,11 @@ export function useFloorplanBackgroundPlacement({
// / draftEnd state in the floor plan would never update, leaving // / draftEnd state in the floor plan would never update, leaving
// the dashed-line draft preview invisible. // the dashed-line draft preview invisible.
if (isWallBuildActive) { if (isWallBuildActive) {
// Wall draft: grid snap only; Shift = fine step. See `wall/tool.tsx`.
const snappedPoint = snapWallDraftPoint({ const snappedPoint = snapWallDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
start: draftStart ?? undefined, step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
angleSnap: Boolean(draftStart) && !shiftPressed,
}) })
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
@@ -12,6 +12,7 @@ import {
type SiteNode, type SiteNode,
type SlabNode, type SlabNode,
type SpawnNode, type SpawnNode,
useLiveTransforms,
useScene, useScene,
type WallNode, type WallNode,
type WindowNode, type WindowNode,
@@ -59,13 +60,22 @@ export function useFloorplanSceneData({
? (levelNode.parentId as BuildingNode['id']) ? (levelNode.parentId as BuildingNode['id'])
: buildingId : 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 if (!currentBuildingId) return 0
const node = state.nodes[currentBuildingId] const node = state.nodes[currentBuildingId]
return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0 return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
}) })
const buildingRotationY = buildingLiveTransform?.rotation ?? committedBuildingRotationY
const buildingPosition = useScene((state) => { const committedBuildingPosition = useScene((state) => {
if (!currentBuildingId) { if (!currentBuildingId) {
return DEFAULT_BUILDING_POSITION return DEFAULT_BUILDING_POSITION
} }
@@ -75,6 +85,7 @@ export function useFloorplanSceneData({
? (node.position as [number, number, number]) ? (node.position as [number, number, number])
: DEFAULT_BUILDING_POSITION : DEFAULT_BUILDING_POSITION
}) })
const buildingPosition = buildingLiveTransform?.position ?? committedBuildingPosition
const site = useScene((state) => { const site = useScene((state) => {
for (const rootNodeId of state.rootNodeIds) { 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 HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16
const BAR_AXIS = new THREE.Vector3(0, 1, 0) 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] type Vec3 = [number, number, number]
@@ -73,8 +79,7 @@ export function WallMeasurementLabel() {
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
const measurableNode = const measurableNode = selectedNode?.type === 'item' ? selectedNode : null
selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
const [objectState, setObjectState] = useState<{ const [objectState, setObjectState] = useState<{
id: AnyNodeId id: AnyNodeId
@@ -447,11 +452,12 @@ function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color:
return ( return (
<mesh <mesh
geometry={SHARED_BAR_GEOMETRY}
position={[segment.position.x, segment.position.y, segment.position.z]} position={[segment.position.x, segment.position.y, segment.position.z]}
quaternion={segment.quaternion} quaternion={segment.quaternion}
renderOrder={1000} renderOrder={1000}
scale={[BAR_THICKNESS, segment.length, BAR_THICKNESS]}
> >
<boxGeometry args={[BAR_THICKNESS, segment.length, BAR_THICKNESS]} />
<meshBasicMaterial <meshBasicMaterial
color={color} color={color}
depthTest={false} depthTest={false}
@@ -8,31 +8,49 @@ import {
getWallThickness, getWallThickness,
isCurvedWall, isCurvedWall,
sceneRegistry, sceneRegistry,
useLiveNodeOverrides,
useScene, useScene,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
BufferGeometry, BufferGeometry,
ConeGeometry, Color,
CylinderGeometry, CylinderGeometry,
DoubleSide, DoubleSide,
Float32BufferAttribute, ExtrudeGeometry,
type Group,
type Object3D, type Object3D,
OrthographicCamera, OrthographicCamera,
Plane,
Quaternion,
Shape,
Vector2,
Vector3,
} from 'three' } 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 { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
const HANDLE_OFFSET = 0.42 const HANDLE_OFFSET = 0.27
const HANDLE_MIN_OFFSET = 0.5 const HANDLE_MIN_OFFSET = 0.33
const HANDLE_MIN_HEIGHT = 0.62 const HANDLE_MIN_HEIGHT = 0.4
const HANDLE_TOP_INSET = 0.08 const HANDLE_TOP_INSET = 0.08
const HEIGHT_HANDLE_OFFSET = 0.26
const MIN_WALL_HEIGHT = 0.5
const ARROW_COLOR = '#8381ed' const ARROW_COLOR = '#8381ed'
const ARROW_HOVER_COLOR = '#a5b4fc' 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 = { type WallMoveHandle = {
key: string key: string
@@ -40,41 +58,49 @@ type WallMoveHandle = {
rotationY: number rotationY: number
} }
function createArrowHandleGeometry() { // Pre-empt the synthetic `click` the browser fires immediately after a
const shaft = new CylinderGeometry(0.04, 0.064, 0.25, 36) // drag's pointerup. Without this, PointerMissedHandler treats the click
const head = new ConeGeometry(0.13, 0.3, 48) // as "missed" and deselects the wall when the height arrow drag commits.
shaft.rotateZ(-Math.PI / 2) function swallowNextClick() {
shaft.translate(-0.085, 0, 0) const swallow = (clickEvent: Event) => {
head.rotateZ(-Math.PI / 2) clickEvent.stopPropagation()
head.translate(0.17, 0, 0) clickEvent.preventDefault()
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()
} }
window.addEventListener('click', swallow, { capture: true, once: true })
setTimeout(() => {
window.removeEventListener('click', swallow, { capture: true })
}, 300)
}
const geometry = new BufferGeometry() function createArrowHandleGeometry() {
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) // Classic arrow silhouette — chevron head + rectangular shaft — extruded
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)) // slightly so the handle reads as a 3D plate but stays visually light.
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) const shape = new Shape()
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2)) 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.computeVertexNormals()
geometry.computeBoundingSphere() geometry.computeBoundingSphere()
return geometry return geometry
@@ -91,9 +117,14 @@ export function WallMoveSideHandles() {
const curvingFence = useEditor((state) => state.curvingFence) const curvingFence = useEditor((state) => state.curvingFence)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null 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 selectedNode = useScene((state) => {
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null 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 = const shouldRender =
@@ -108,11 +139,7 @@ export function WallMoveSideHandles() {
if (!shouldRender || !selectedNode) return null if (!shouldRender || !selectedNode) return null
return selectedNode.type === 'wall' ? ( return <WallMoveSideHandlesForWall wall={selectedNode} />
<WallMoveSideHandlesForWall wall={selectedNode} />
) : (
<WallMoveSideHandlesForFence fence={selectedNode} />
)
} }
function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
@@ -157,19 +184,368 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
{handles.map((handle) => ( {handles.map((handle) => (
<WallMoveArrowHandle handle={handle} key={handle.key} wall={wall} /> <WallMoveArrowHandle handle={handle} key={handle.key} wall={wall} />
))} ))}
<WallHeightArrowHandle wall={wall} />
<WallCornerLeaderHandle endpoint="start" wall={wall} />
<WallCornerLeaderHandle endpoint="end" wall={wall} />
</group>, </group>,
levelObject, 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 }) { function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) 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 { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 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(() => { useEffect(() => {
return () => { return () => {
@@ -180,10 +556,10 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
}, []) }, [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
const activateWallMove = (event: ThreeEvent<PointerEvent>) => { const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation() event.stopPropagation()
event.nativeEvent.preventDefault()
document.body.style.cursor = 'grabbing' document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
@@ -192,14 +568,21 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
useEditor.getState().setMovingFenceEndpoint(null) useEditor.getState().setMovingFenceEndpoint(null)
useEditor.getState().setCurvingWall(null) useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(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 ( return (
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}> <group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
<mesh <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} frustumCulled={false}
layers={EDITOR_LAYER} geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={activateWallMove} onPointerDown={activateWallMove}
onPointerEnter={(event) => { onPointerEnter={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -214,17 +597,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
} }
}} }}
renderOrder={1002} 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> </group>
) )
} }
@@ -232,10 +605,26 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) { function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) 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 { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 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(() => { useEffect(() => {
return () => { return () => {
@@ -246,10 +635,10 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
}, []) }, [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => { const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation() event.stopPropagation()
event.nativeEvent.preventDefault()
document.body.style.cursor = 'grabbing' document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
@@ -258,14 +647,18 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
useEditor.getState().setMovingFenceEndpoint(null) useEditor.getState().setMovingFenceEndpoint(null)
useEditor.getState().setCurvingWall(null) useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null) useEditor.getState().setCurvingFence(null)
useViewer.getState().setSelection({ selectedIds: [] }) // Keep the fence selected so it stays active once the move commits.
} }
return ( return (
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}> <group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
<mesh <mesh
// Pass geometry as a prop — see WallMoveArrowHandle for the
// WebGPU "Vertex buffer slot 0 ... was not set" rationale.
frustumCulled={false} frustumCulled={false}
layers={EDITOR_LAYER} geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={activateFenceMove} onPointerDown={activateFenceMove}
onPointerEnter={(event) => { onPointerEnter={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -280,17 +673,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
} }
}} }}
renderOrder={1002} 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> </group>
) )
} }
@@ -5,6 +5,7 @@ import {
emitter, emitter,
type GridEvent, type GridEvent,
sceneRegistry, sceneRegistry,
useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -15,6 +16,8 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
const Y_AXIS = new THREE.Vector3(0, 1, 0)
export function MoveBuildingContent({ node }: { node: BuildingNode }) { export function MoveBuildingContent({ node }: { node: BuildingNode }) {
const previousGridPosRef = useRef<[number, number] | null>(null) 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 originalRotationRef = useRef<number>(node.rotation[1] ?? 0)
const pendingRotationRef = 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 [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(node.id) const obj = sceneRegistry.nodes.get(node.id)
if (obj) { 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() const pos = new THREE.Vector3()
obj.getWorldPosition(pos) obj.getWorldPosition(pos)
return [pos.x, pos.y, pos.z] return [pos.x, pos.y, pos.z]
@@ -45,9 +66,22 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
useEffect(() => { useEffect(() => {
const nodeId = nodeIdRef.current const nodeId = nodeIdRef.current
const originalPosition = originalPositionRef.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() 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 let wasCommitted = false
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -66,7 +100,19 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
pendingRotationRef.current += rotationDelta pendingRotationRef.current += rotationDelta
const mesh = sceneRegistry.nodes.get(nodeId) 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 // Directly update the Three.js group — no store update during drag
const mesh = sceneRegistry.nodes.get(nodeId) const mesh = sceneRegistry.nodes.get(nodeId)
if (mesh) { if (mesh) {
mesh.position.x = gridX const off = offsetAt(pendingRotationRef.current)
mesh.position.z = gridZ 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 wasCommitted = true
const off = offsetAt(pendingRotationRef.current)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { useScene.getState().updateNode(nodeId, {
position: [gridX, originalPosition[1], gridZ], position: [gridX - off.x, originalPosition[1], gridZ - off.z],
rotation: [0, pendingRotationRef.current, 0], rotation: [0, pendingRotationRef.current, 0],
}) })
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -140,6 +189,10 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
rotation: [0, originalRotationRef.current, 0], 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() useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
@@ -11,8 +11,8 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import { import {
findWallSnapTarget, findWallSnapTarget,
getWallAngleSnapStep, getWallAngleSnapStep,
getWallGridStep, getSegmentGridStep,
isWallLongEnough, isSegmentLongEnough,
snapPointTo45Degrees, snapPointTo45Degrees,
snapPointToGrid, snapPointToGrid,
type WallPlanPoint, type WallPlanPoint,
@@ -131,9 +131,11 @@ export function snapFenceDraftPoint(args: {
start?: FencePlanPoint start?: FencePlanPoint
angleSnap?: boolean angleSnap?: boolean
ignoreFenceIds?: string[] ignoreFenceIds?: string[]
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
step?: number
}): FencePlanPoint { }): FencePlanPoint {
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step } = args
const gridStep = getWallGridStep() const gridStep = step ?? getSegmentGridStep()
const angleStep = getWallAngleSnapStep(gridStep) const angleStep = getWallAngleSnapStep(gridStep)
const basePoint = const basePoint =
start && angleSnap start && angleSnap
@@ -151,7 +153,7 @@ export function createFenceOnCurrentLevel(
const currentLevelId = useViewer.getState().selection.levelId const currentLevelId = useViewer.getState().selection.levelId
const { createNode, nodes } = useScene.getState() const { createNode, nodes } = useScene.getState()
if (!(currentLevelId && isWallLongEnough(start, end))) { if (!(currentLevelId && isSegmentLongEnough(start, end))) {
return null return null
} }
@@ -341,7 +341,16 @@ export const MoveRoofTool: React.FC<{
// Clear ephemeral live transform // Clear ephemeral live transform
useLiveTransforms.getState().clear(movingNode.id) 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, { useScene.getState().updateNode(movingNode.id, {
position: original.position, position: original.position,
rotation: original.rotation, rotation: original.rotation,
@@ -1,12 +1,55 @@
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { createPortal } from '@react-three/fiber' import { createPortal } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { snapToHalf } from '../item/placement-math' import { snapToHalf } from '../item/placement-math'
const Y_OFFSET = 0.02 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 = { type DragState = {
isDragging: boolean isDragging: boolean
@@ -23,6 +66,14 @@ export interface PolygonEditorProps {
polygon: Array<[number, number]> polygon: Array<[number, number]>
color?: string color?: string
onPolygonChange: (polygon: Array<[number, number]>) => void 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 minVertices?: number
/** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */ /** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
levelId?: string levelId?: string
@@ -55,6 +106,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
polygon, polygon,
color = '#3b82f6', color = '#3b82f6',
onPolygonChange, onPolygonChange,
onPolygonPreview,
minVertices = 3, minVertices = 3,
levelId, levelId,
surfaceHeight = 0, surfaceHeight = 0,
@@ -105,9 +157,19 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null) const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const previewPolygonRef = useRef<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) => { const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
previewPolygonRef.current = nextPolygon previewPolygonRef.current = nextPolygon
setPreviewPolygon(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 // Keep ref in sync
@@ -159,6 +221,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const edgeHandles = useMemo(() => { const edgeHandles = useMemo(() => {
if (displayPolygon.length < 2) return [] 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) => { return displayPolygon.flatMap(([x1, z1], index) => {
const nextIndex = (index + 1) % displayPolygon.length const nextIndex = (index + 1) % displayPolygon.length
const [x2, z2] = displayPolygon[nextIndex]! const [x2, z2] = displayPolygon[nextIndex]!
@@ -167,17 +238,33 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const length = Math.hypot(dx, dz) const length = Math.hypot(dx, dz)
if (length < 1e-6) return [] 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 [ return [
{ {
index, index,
length, length,
midpoint: [(x1 + x2) / 2, (z1 + z2) / 2] as [number, number], midpoint,
rotationY: -Math.atan2(dz, dx), rotationY: -Math.atan2(dz, dx),
outwardNormal: [nx, nz] as [number, number],
outwardAngle: -Math.atan2(nz, nx),
}, },
] ]
}) })
}, [displayPolygon]) }, [displayPolygon])
const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
// Update vertex position using grid cursor position // Update vertex position using grid cursor position
const handleVertexDrag = useCallback( const handleVertexDrag = useCallback(
(vertexIndex: number, position: [number, number]) => { (vertexIndex: number, position: [number, number]) => {
@@ -427,7 +514,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
position={[x!, editY + height / 2, z!]} position={[x!, editY + height / 2, z!]}
> >
<cylinderGeometry args={[radius, radius, height, 16]} /> <cylinderGeometry args={[radius, radius, height, 16]} />
<meshStandardMaterial <meshBasicMaterial
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'} color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
/> />
</mesh> </mesh>
@@ -458,63 +545,107 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]} position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
> >
<sphereGeometry args={[0.09, 20, 20]} /> <sphereGeometry args={[0.09, 20, 20]} />
<meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} /> <meshBasicMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
</mesh> </mesh>
)} )}
{allowEdgeMove && {allowEdgeMove &&
edgeHandles.map(({ index, length, midpoint, rotationY }) => { edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
const isHovered = hoveredEdge === index const isHovered = hoveredEdge === index
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === 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 ( return (
<mesh <group key={`edge-${index}`}>
key={`edge-${index}`} <mesh
layers={EDITOR_LAYER} layers={EDITOR_LAYER}
onClick={(e) => { onClick={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
const start = displayPolygon[index] beginEdgeDrag(e)
const end = displayPolygon[(index + 1) % displayPolygon.length] }}
if (!(start && end)) return onPointerEnter={(e) => {
e.stopPropagation()
const edgeNormal = getEdgeNormal(start, end) setHoveredEdge(index)
if (!edgeNormal) return }}
onPointerLeave={(e) => {
setHoveredEdge(null) e.stopPropagation()
setDragState({ setHoveredEdge(null)
isDragging: true, }}
mode: 'edge', position={[midpoint[0], edgeHandleY, midpoint[1]]}
vertexIndex: null, rotation={[0, rotationY, 0]}
edgeIndex: index, >
edgeNormal, <boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
initialPosition: cursorPosition, <meshBasicMaterial
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]), color={isDragging ? '#22c55e' : '#60a5fa'}
pointerId: e.pointerId, opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
}) transparent
}} />
onPointerEnter={(e) => { </mesh>
e.stopPropagation() {/* Per-side resize arrow — points outward from the edge.
setHoveredEdge(index) Dragging it pulls (or pushes) only this edge's two
}} vertices along the outward normal; the opposite side
onPointerLeave={(e) => { of the polygon stays put. */}
e.stopPropagation() <mesh
setHoveredEdge(null) geometry={arrowGeometry}
}} layers={EDITOR_LAYER}
position={[midpoint[0], edgeHandleY, midpoint[1]]} onClick={(e) => {
rotation={[0, rotationY, 0]} if (e.button !== 0) return
> e.stopPropagation()
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} /> }}
<meshStandardMaterial onPointerDown={(e) => {
color={isDragging ? '#22c55e' : '#94a3b8'} if (e.button !== 0) return
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14} e.stopPropagation()
transparent beginEdgeDrag(e)
/> }}
</mesh> 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!]} position={[x!, editY + height / 2, z!]}
> >
<cylinderGeometry args={[radius, radius, height, 16]} /> <cylinderGeometry args={[radius, radius, height, 16]} />
<meshStandardMaterial <meshBasicMaterial
color={isHovered ? '#4ade80' : '#22c55e'} color={isHovered ? '#4ade80' : '#22c55e'}
opacity={isHovered ? 1 : 0.7} opacity={isHovered ? 1 : 0.7}
transparent 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_TOP_LANDING_DEPTH = 0.9
export const DEFAULT_SPIRAL_SHOW_CENTER_COLUMN = true export const DEFAULT_SPIRAL_SHOW_CENTER_COLUMN = true
export const DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS = 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 export const DEFAULT_STAIR_RAILING_HEIGHT = 0.92
@@ -19,7 +19,15 @@ import useEditor from '../../../store/use-editor'
export type WallPlanPoint = [number, number] export type WallPlanPoint = [number, number]
export const WALL_GRID_STEP = 0.5 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 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 export const WALL_MIN_LENGTH = 0.01
const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4 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 return dx * dx + dz * dz
} }
export function getWallGridStep(): number { export function getSegmentGridStep(): number {
return useEditor.getState().gridSnapStep 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 return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
} }
@@ -371,15 +379,56 @@ export function findWallSnapTarget(
return bestTarget 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: { export function snapWallDraftPoint(args: {
point: WallPlanPoint point: WallPlanPoint
walls: WallNode[] walls: WallNode[]
start?: WallPlanPoint start?: WallPlanPoint
angleSnap?: boolean angleSnap?: boolean
ignoreWallIds?: string[] ignoreWallIds?: string[]
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
step?: number
}): WallPlanPoint { }): WallPlanPoint {
const { point, walls, start, angleSnap = false, ignoreWallIds } = args const { point, walls, start, angleSnap = false, ignoreWallIds, step: overrideStep } = args
const step = getWallGridStep()
// 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 angleStep = getWallAngleSnapStep(step)
const basePoint = const basePoint =
start && angleSnap 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 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 { createNode, createNodes, deleteNode, nodes } = useScene.getState()
const { updateNodes } = useScene.getState() const { updateNodes } = useScene.getState()
if (!(currentLevelId && isWallLongEnough(start, end))) { if (!(currentLevelId && isSegmentLongEnough(start, end))) {
return null return null
} }
@@ -444,7 +493,7 @@ export function createWallOnCurrentLevel(
resolvedStart = splitStart.point resolvedStart = splitStart.point
} }
if (!isWallLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) { if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
return null return null
} }
@@ -13,6 +13,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react' import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { getLevelDisplayName } from '../../../lib/level-name'
import { createLocalGuideImage } from '../../../lib/local-guide-image' import { createLocalGuideImage } from '../../../lib/local-guide-image'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor' 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 ────────────────────────────────────── // ── Shared upload button for dropdowns ──────────────────────────────────────
function UploadButton({ onError }: { onError: (message: string | null) => void }) { function UploadButton({ onError }: { onError: (message: string | null) => void }) {
@@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'
import { create } from 'zustand' import { create } from 'zustand'
import { useShallow } from 'zustand/shallow' import { useShallow } from 'zustand/shallow'
import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog' import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog'
import { getLevelDisplayName } from '../../../lib/level-name'
import { useCommandRegistry } from '../../../store/use-command-registry' import { useCommandRegistry } from '../../../store/use-command-registry'
import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry' import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry'
@@ -404,7 +405,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
<OptionItem <OptionItem
isActive={level.id === activeLevelId} isActive={level.id === activeLevelId}
key={level.id} key={level.id}
label={level.name ?? `Level ${level.level}`} label={getLevelDisplayName(level)}
onSelect={() => onSelect={() =>
run(() => useViewer.getState().setSelection({ levelId: level.id })) run(() => useViewer.getState().setSelection({ levelId: level.id }))
} }
@@ -41,6 +41,7 @@ import {
buildLevelDuplicateCreateOps, buildLevelDuplicateCreateOps,
type LevelDuplicatePreset, type LevelDuplicatePreset,
} from '../../lib/level-duplication' } from '../../lib/level-duplication'
import { getDefaultLevelName, getLevelDisplayName } from '../../lib/level-name'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import { import {
getEditorClipboardSnapshot, getEditorClipboardSnapshot,
@@ -60,10 +61,6 @@ import {
} from './primitives/dialog' } from './primitives/dialog'
import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover' import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover'
function getLevelDisplayLabel(level: LevelNode) {
return level.name || `Level ${level.level}`
}
// ── Inline rename input for a level row ───────────────────────────────────── // ── Inline rename input for a level row ─────────────────────────────────────
function LevelInlineRename({ function LevelInlineRename({
@@ -76,7 +73,7 @@ function LevelInlineRename({
onStopEditing: () => void onStopEditing: () => void
}) { }) {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const defaultName = `Level ${level.level}` const defaultName = getDefaultLevelName(level.level)
const [value, setValue] = useState(level.name || '') const [value, setValue] = useState(level.name || '')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@@ -169,7 +166,7 @@ function LevelRow({
> >
<button <button
{...dragHandleProps} {...dragHandleProps}
aria-label={`Reorder ${getLevelDisplayLabel(level)}`} aria-label={`Reorder ${getLevelDisplayName(level)}`}
className={cn( 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', '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', isDragging && 'cursor-grabbing opacity-100',
@@ -192,10 +189,10 @@ function LevelRow({
e.stopPropagation() e.stopPropagation()
setIsEditing(true) setIsEditing(true)
}} }}
title={getLevelDisplayLabel(level)} title={getLevelDisplayName(level)}
type="button" type="button"
> >
<span className="truncate">{getLevelDisplayLabel(level)}</span> <span className="truncate">{getLevelDisplayName(level)}</span>
</button> </button>
{/* Vertical three-dot menu — inside the pill */} {/* Vertical three-dot menu — inside the pill */}
@@ -599,7 +596,7 @@ export function FloatingLevelSelector() {
<DialogTitle>Delete level</DialogTitle> <DialogTitle>Delete level</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete{' '} 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. walls, floors, and objects on this level will be permanently removed.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -3,6 +3,7 @@
import type { LevelNode } from '@pascal-app/core' import type { LevelNode } from '@pascal-app/core'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { LevelDuplicatePreset } from '../../lib/level-duplication' import type { LevelDuplicatePreset } from '../../lib/level-duplication'
import { getLevelDisplayName } from '../../lib/level-name'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { import {
Dialog, Dialog,
@@ -42,7 +43,7 @@ const DUPLICATE_PRESETS: Array<{
function getLevelLabel(level: LevelNode | null) { function getLevelLabel(level: LevelNode | null) {
if (!level) return 'this level' if (!level) return 'this level'
return level.name || `Level ${level.level}` return getLevelDisplayName(level)
} }
export function LevelDuplicateDialog({ export function LevelDuplicateDialog({
@@ -36,6 +36,7 @@ import {
buildLevelDuplicateCreateOps, buildLevelDuplicateCreateOps,
type LevelDuplicatePreset, type LevelDuplicatePreset,
} from './../../../../../lib/level-duplication' } from './../../../../../lib/level-duplication'
import { getDefaultLevelName } from './../../../../../lib/level-name'
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
import { createLocalGuideImage } from './../../../../../lib/local-guide-image' import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
import { cn } from './../../../../../lib/utils' import { cn } from './../../../../../lib/utils'
@@ -723,7 +724,7 @@ const LevelItem = memo(function LevelItem({
src="/icons/level.png" src="/icons/level.png"
/> />
<InlineRenameInput <InlineRenameInput
defaultName={`Level ${level.level}`} defaultName={getDefaultLevelName(level.level)}
isEditing={isEditing} isEditing={isEditing}
nodeId={level.id} nodeId={level.id}
onStartEditing={() => setIsEditing(true)} onStartEditing={() => setIsEditing(true)}
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Layers } from 'lucide-react' import { Layers } from 'lucide-react'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { getDefaultLevelName } from '../../../../../lib/level-name'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node' import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
@@ -35,7 +36,7 @@ export const LevelTreeNode = memo(function LevelTreeNode({
const handleStartEditing = useCallback(() => setIsEditing(true), []) const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), []) const handleStopEditing = useCallback(() => setIsEditing(false), [])
const defaultName = `Level ${level}` const defaultName = getDefaultLevelName(level)
return ( return (
<TreeNodeWrapper <TreeNodeWrapper
@@ -30,6 +30,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { getLevelDisplayName } from '../lib/level-name'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import { ActionButton } from './ui/action-menu/action-button' import { ActionButton } from './ui/action-menu/action-button'
import { import {
@@ -363,7 +364,7 @@ export const ViewerOverlay = ({
className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`} className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
onClick={() => handleBreadcrumbClick('level')} onClick={() => handleBreadcrumbClick('level')}
> >
{level.name || `Level ${level.level}`} {getLevelDisplayName(level)}
</button> </button>
</> </>
)} )}
+53 -35
View File
@@ -1,14 +1,14 @@
import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction' import { toggleDoorOpenState } from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import { import {
copySelectedNodesToEditorClipboard, copySelectedNodesToEditorClipboard,
pasteEditorClipboardToLevel, pasteEditorClipboardToLevel,
} from '../lib/scene-clipboard' } from '../lib/scene-clipboard'
import { sfxEmitter } from '../lib/sfx-bus' 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' import useEditor from '../store/use-editor'
// Tools call this in their onCancel handler when they have an active mid-action to cancel, // 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) { } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.) // 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[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) { if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!] const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node?.type === 'door') { if (node?.type === 'door') {
e.preventDefault() e.preventDefault()
if (node.openingKind !== 'opening') { useScene.getState().updateNode(node.id, {
toggleDoorOpenState(node.id) side: node.side === 'front' ? 'back' : 'front',
sfxEmitter.emit('sfx:item-rotate') 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 ( sfxEmitter.emit('sfx:item-rotate')
node?.type === 'window' && } else if (node?.type === 'window') {
node.openingKind !== 'opening' && // Windows: R flips side (front ↔ back, rotation += π). Open/
(node.windowType === 'sliding' || // close toggle for operable windows lives on E.
node.windowType === 'casement' ||
node.windowType === 'awning' ||
node.windowType === 'hopper' ||
node.windowType === 'single-hung' ||
node.windowType === 'double-hung' ||
node.windowType === 'louvered')
) {
e.preventDefault() 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') sfxEmitter.emit('sfx:item-rotate')
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.r?.appliesTo(node)) { } else if (node && nodeRegistry.get(node.type)?.keyboardActions?.r?.appliesTo(node)) {
// Registry-driven R action. Skylight uses this for open/ // Registry-driven R action. Skylight uses this for open/
@@ -227,25 +231,13 @@ export const useKeyboard = ({
if (selectedNodeIds.length === 1) { if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!] const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node?.type === 'door') { 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() e.preventDefault()
if (node.openingKind !== 'opening') { } else if (node?.type === 'window') {
closeDoorOpenState(node.id) // Window's open/close moved to E; T is a no-op so it doesn't
sfxEmitter.emit('sfx:item-rotate') // free-rotate a wall-bound node by π/4.
}
} 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() e.preventDefault()
closeWindowOpenState(node.id)
sfxEmitter.emit('sfx:item-rotate')
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.t?.appliesTo(node)) { } else if (node && nodeRegistry.get(node.type)?.keyboardActions?.t?.appliesTo(node)) {
// Registry-driven T action. Same shape as the R arm above. // Registry-driven T action. Same shape as the R arm above.
e.preventDefault() e.preventDefault()
@@ -265,6 +257,32 @@ export const useKeyboard = ({
sfxEmitter.emit('sfx:item-rotate') 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) { } else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) {
e.preventDefault() e.preventDefault()
+5 -2
View File
@@ -81,11 +81,12 @@ export {
} from './components/tools/stair/stair-defaults' } from './components/tools/stair/stair-defaults'
export { export {
createWallOnCurrentLevel, createWallOnCurrentLevel,
getWallGridStep, getSegmentGridStep,
isWallLongEnough, isSegmentLongEnough,
snapPointToGrid, snapPointToGrid,
snapScalarToGrid, snapScalarToGrid,
snapWallDraftPoint, snapWallDraftPoint,
WALL_FINE_GRID_STEP,
type WallPlanPoint, type WallPlanPoint,
} from './components/tools/wall/wall-drafting' } from './components/tools/wall/wall-drafting'
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions' export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
@@ -158,6 +159,7 @@ export {
type FloorplanStairArrowEntry, type FloorplanStairArrowEntry,
type FloorplanStairEntry, type FloorplanStairEntry,
type FloorplanStairSegmentEntry, type FloorplanStairSegmentEntry,
getFloorplanWallThickness,
} from './lib/floorplan' } from './lib/floorplan'
export { export {
buildRoofSurfaceMaterialPatch, buildRoofSurfaceMaterialPatch,
@@ -192,3 +194,4 @@ export {
usePaletteViewRegistry, usePaletteViewRegistry,
} from './store/use-palette-view-registry' } from './store/use-palette-view-registry'
export { useUploadStore } from './store/use-upload' 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 | BuildingNode
| null, | null,
) => void ) => 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 movingWallEndpoint: MovingWallEndpoint | null
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
movingFenceEndpoint: MovingFenceEndpoint | null movingFenceEndpoint: MovingFenceEndpoint | null
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void 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 curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void setCurvingWall: (wall: WallNode | null) => void
curvingFence: FenceNode | null curvingFence: FenceNode | null
@@ -597,11 +628,24 @@ const useEditor = create<EditorState>()(
| StairSegmentNode | StairSegmentNode
| BuildingNode | BuildingNode
| null, | 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, movingWallEndpoint: null,
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }), setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
movingFenceEndpoint: null, movingFenceEndpoint: null,
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }), setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
activeHandleDrag: null,
setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
curvingWall: null, curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }), setCurvingWall: (wall) => set({ curvingWall: wall }),
curvingFence: null, 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`. // selection, never 3D click. Same reasoning as `level` / `site`.
duplicable: false, duplicable: false,
deletable: false, deletable: false,
floorplanLevelContainer: true,
}, },
parametrics: buildingParametrics, parametrics: buildingParametrics,
+34 -2
View File
@@ -1,9 +1,14 @@
'use client' '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 { PolygonEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react' import { useCallback, useEffect } from 'react'
/** /**
* Phase 5 Stage D — ceiling boundary editor (registry-driven). * 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 * Thin wrapper around the shared `<PolygonEditor>` (same shape as
* slab's boundary-editor). Activates when a ceiling is selected in * slab's boundary-editor). Activates when a ceiling is selected in
* structure/select mode and no hole edit is in progress. * 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'] }> = ({ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = ({
ceilingId, ceilingId,
}) => { }) => {
const ceilingNode = useScene((s) => s.nodes[ceilingId]) const ceilingNode = useScene((s) => s.nodes[ceilingId])
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const markDirty = useScene((s) => s.markDirty)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
@@ -29,6 +39,27 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
[ceilingId, updateNode, setSelection], [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 if (!ceiling?.polygon || ceiling.polygon.length < 3) return null
return ( return (
@@ -38,6 +69,7 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
minVertices={3} minVertices={3}
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
polygon={ceiling.polygon} polygon={ceiling.polygon}
surfaceHeight={ceiling.height ?? 2.5} 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 { buildCeilingFloorplan } from './floorplan'
import { import {
ceilingAddVertexAffordance, ceilingAddVertexAffordance,
@@ -9,6 +13,52 @@ import { ceilingFloorplanMoveTarget } from './floorplan-move'
import { ceilingParametrics } from './parametrics' import { ceilingParametrics } from './parametrics'
import { CeilingNode } from './schema' 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 * Ceiling — Phase 5 batch kind, polygon-based. Structurally similar to
* slab but with React-rendered hosted children + TSL shader materials + * slab but with React-rendered hosted children + TSL shader materials +
@@ -60,6 +110,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
}, },
parametrics: ceilingParametrics, parametrics: ceilingParametrics,
handles: ceilingHandles,
// Stage D: kind-owned placement tool. Multi-click polygon drawing // Stage D: kind-owned placement tool. Multi-click polygon drawing
// with a vertical TSL-gradient connector + ground-shadow lines. // with a vertical TSL-gradient connector + ground-shadow lines.
+30 -2
View File
@@ -1,9 +1,14 @@
'use client' '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 { PolygonEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react' import { useCallback, useEffect } from 'react'
/** /**
* Phase 5 Stage D — ceiling hole editor (registry-driven). * Phase 5 Stage D — ceiling hole editor (registry-driven).
@@ -14,6 +19,7 @@ export const CeilingHoleEditor: React.FC<{
}> = ({ ceilingId, holeIndex }) => { }> = ({ ceilingId, holeIndex }) => {
const ceilingNode = useScene((s) => s.nodes[ceilingId]) const ceilingNode = useScene((s) => s.nodes[ceilingId])
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const markDirty = useScene((s) => s.markDirty)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
@@ -30,6 +36,27 @@ export const CeilingHoleEditor: React.FC<{
[ceilingId, holeIndex, holes, updateNode, setSelection], [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 if (!(ceiling && hole) || hole.length < 3) return null
return ( return (
@@ -40,6 +67,7 @@ export const CeilingHoleEditor: React.FC<{
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
minVertices={3} minVertices={3}
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
polygon={hole} polygon={hole}
surfaceHeight={ceiling.height ?? 2.5} surfaceHeight={ceiling.height ?? 2.5}
/> />
+286
View File
@@ -1,12 +1,284 @@
import { import {
ColumnNode as ColumnNodeSchema, ColumnNode as ColumnNodeSchema,
type ColumnNode as ColumnNodeType, type ColumnNode as ColumnNodeType,
type HandleDescriptor,
type NodeDefinition, type NodeDefinition,
} from '@pascal-app/core' } from '@pascal-app/core'
import { buildColumnFloorplan } from './floorplan' import { buildColumnFloorplan } from './floorplan'
import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances'
import { columnParametrics } from './parametrics' import { columnParametrics } from './parametrics'
import { ColumnNode } from './schema' 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 * Column — Stage A registration. Wrap-export of the legacy
* `ColumnRenderer` (no system — column geometry is computed inline in * `ColumnRenderer` (no system — column geometry is computed inline in
@@ -54,6 +326,7 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
}, },
parametrics: columnParametrics, parametrics: columnParametrics,
handles: columnHandles,
renderer: { renderer: {
kind: 'parametric', kind: 'parametric',
@@ -66,6 +339,19 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
move: () => import('./move-tool'), move: () => import('./move-tool'),
}, },
floorplan: buildColumnFloorplan, 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: { presentation: {
label: 'Column', 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, FloorplanPoint,
GeometryContext, GeometryContext,
} from '@pascal-app/core' } 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 * 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 * a-frame / x-brace / etc.) — brace supports use a rotated rectangle
* spanning the base spread; standalone columns use the shaft profile. * spanning the base spread; standalone columns use the shaft profile.
* *
* When selected, switches to a themed accent stroke and emits a move * When selected, switches to a themed accent stroke and emits the
* handle at the column center. No dimension overlay (columns don't * orange move-handle dot, four perpendicular side move-arrows for
* have a natural "length" axis like a wall). * dragging the body, and a rotate-arrow at the front-right corner.
*/ */
export function buildColumnFloorplan( export function buildColumnFloorplan(
node: ColumnNode, 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) { if (isSelected) {
children.push({ children.push({
kind: 'move-handle', kind: 'move-handle',
point: [node.position[0], node.position[2]], 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 } return { kind: 'group', children }
@@ -71,6 +167,39 @@ export function buildColumnFloorplan(
type PlanPoint = { x: number; y: number } 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] { function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
const c = Math.cos(rotation) const c = Math.cos(rotation)
const s = Math.sin(rotation) const s = Math.sin(rotation)
@@ -147,7 +276,7 @@ function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
column.braceDepth ?? column.depth, column.braceDepth ?? column.depth,
0.08, 0.08,
) )
return getRotatedRectanglePolygon(center, width, depth, column.rotation) return getRotatedRectanglePolygon(center, width, depth, -column.rotation)
} }
// Standalone column: shaft profile expanded for base + capital. // Standalone column: shaft profile expanded for base + capital.
@@ -169,7 +298,7 @@ function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
) )
if (column.crossSection === 'square' || column.crossSection === 'rectangular') { if (column.crossSection === 'square' || column.crossSection === 'rectangular') {
return getRotatedRectanglePolygon(center, width, depth, column.rotation) return getRotatedRectanglePolygon(center, width, depth, -column.rotation)
} }
const segmentCount = const segmentCount =
@@ -179,7 +308,7 @@ function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
const angle = (index / segmentCount) * Math.PI * 2 const angle = (index / segmentCount) * Math.PI * 2
const localX = Math.cos(angle) * (width / 2) const localX = Math.cos(angle) * (width / 2)
const localY = Math.sin(angle) * (depth / 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 } 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' }, { 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) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)) return Math.min(max, Math.max(min, value))
} }
@@ -260,19 +337,27 @@ export default function ColumnPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Shape"> <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) => { {SUPPORT_STYLE_OPTIONS.map((option) => {
const isSelected = supportStyle === option.value const isSelected = supportStyle === option.value
return ( return (
<button <button
className={cn( 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 isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground' ? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground', : 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
)} )}
key={option.value} 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({ handleUpdate({
supportStyle: option.value, supportStyle: option.value,
...(option.value !== 'vertical' ...(option.value !== 'vertical'
@@ -284,8 +369,9 @@ export default function ColumnPanel() {
capitalStyle: 'none', capitalStyle: 'none',
} }
: {}), : {}),
...stylePreset,
}) })
} }}
type="button" type="button"
> >
<span className="truncate font-medium">{option.label}</span> <span className="truncate font-medium">{option.label}</span>
+16 -2
View File
@@ -1,6 +1,11 @@
'use client' 'use client'
import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' import {
type ColumnNode,
useLiveNodeOverrides,
useLiveTransforms,
useRegistry,
} from '@pascal-app/core'
import { import {
baseMaterial, baseMaterial,
type ColorPreset, 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!) 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 handlers = useNodeEvents(node, 'column')
const liveTransform = useLiveTransforms((state) => state.get(node.id)) const liveTransform = useLiveTransforms((state) => state.get(node.id))
const shading = useViewer((state) => state.shading) 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 { buildDoorFloorplan } from './floorplan'
import { doorFloorplanMoveTarget } from './floorplan-move' import { doorFloorplanMoveTarget } from './floorplan-move'
import { doorParametrics } from './parametrics' import { doorParametrics } from './parametrics'
import { DoorNode } from './schema' 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, * Door — Phase 5 batch kind. Hosted on walls, cuts holes in them,
* animated open/close state. * animated open/close state.
@@ -43,9 +142,11 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
selectable: { hitVolume: 'bbox' }, selectable: { hitVolume: 'bbox' },
duplicable: true, duplicable: true,
deletable: true, deletable: true,
wallOpeningPlacement: true,
}, },
parametrics: doorParametrics, parametrics: doorParametrics,
handles: doorHandles,
renderer: { renderer: {
kind: 'parametric', kind: 'parametric',
@@ -78,6 +179,13 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
// local-X to 0.5m, clamps inside wall bounds. // local-X to 0.5m, clamps inside wall bounds.
floorplanMoveTarget: doorFloorplanMoveTarget, 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: [ toolHints: [
{ key: 'Left click', label: 'Place door on wall' }, { key: 'Left click', label: 'Place door on wall' },
{ key: 'Esc', label: 'Cancel' }, { 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 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 = { const session: FloorplanMoveTargetSession = {
affectedIds: [node.id as AnyNodeId], affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
@@ -48,6 +59,14 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX) const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) 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 + // Build the updates atomically — position + rotation + side +
// parentId + wallId in a single scene write. The current door's // parentId + wallId in a single scene write. The current door's
// parent might be a different wall; re-anchoring requires moving // parent might be a different wall; re-anchoring requires moving
@@ -56,13 +75,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
useScene.getState().updateNodes([ useScene.getState().updateNodes([
{ {
id: node.id as AnyNodeId, id: node.id as AnyNodeId,
data: { data: lastValid,
position: [clampedX, clampedY, 0],
rotation: [0, hit.itemRotation, 0],
side: hit.side,
parentId: hit.wall.id,
wallId: hit.wall.id,
},
}, },
]) ])
}, },
@@ -81,6 +94,23 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
) )
return !overlapping 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 return session
+23
View File
@@ -183,6 +183,29 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
kind: 'move-handle', kind: 'move-handle',
point: [cx, cz], 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 // Placement-measurement dimensions — distances to adjacent openings
+3 -3
View File
@@ -650,13 +650,13 @@ export default function DoorPanel() {
/> />
</div> </div>
{!isOpening && ( {!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) => { {(isGarageDoor ? garageDoorTypeOptions : doorTypeOptions).map((option) => {
const isSelected = doorType === option.value const isSelected = doorType === option.value
return ( return (
<button <button
className={cn( 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 isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground' ? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover: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))} onClick={() => handleUpdate(getDoorTypeUpdates(option.value))}
type="button" 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> <span className="truncate font-medium">{option.label}</span>
</button> </button>
) )
+16 -11
View File
@@ -154,8 +154,22 @@ const DoorTool: React.FC = () => {
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
if (draftRef.current) { if (draftRef.current) {
if (event.node.id !== draftRef.current.parentId) { // Update the scene store on every move so the 2D floor plan
// Wall changed without enter/leave: must updateNode to reparent // 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, { useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0], position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
@@ -163,15 +177,6 @@ const DoorTool: React.FC = () => {
parentId: event.node.id, parentId: event.node.id,
wallId: 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 { buildElevatorFloorplan } from './floorplan'
import { elevatorParametrics } from './parametrics' import { elevatorParametrics } from './parametrics'
import { ElevatorNode } from './schema' 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 + * Elevator — Stage A registration. Wrap-exports the legacy renderer +
* the three legacy systems (runtime / interaction / opening) bundled * the three legacy systems (runtime / interaction / opening) bundled
@@ -25,11 +164,17 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
capabilities: { capabilities: {
selectable: { hitVolume: 'bbox' }, 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, duplicable: true,
deletable: true, deletable: true,
}, },
parametrics: elevatorParametrics, parametrics: elevatorParametrics,
handles: elevatorHandles,
renderer: { renderer: {
kind: 'parametric', kind: 'parametric',
@@ -40,6 +185,21 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
priority: 3, priority: 3,
}, },
floorplan: buildElevatorFloorplan, 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: { presentation: {
label: 'Elevator', 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' } 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). * - **Outer shaft outline** — outer face of the shaft wall.
* - **Cab indicator** — inner rectangle showing the cab's position within * - **Inner shaft outline** — inner face of the shaft wall, offset
* the shaft. Highlighted when `runtime.currentLevelId` matches the * inward by `shaftWallThickness`. The two outlines together read as
* active level (i.e. the car is *on this floor*). * a hollow wall in plan.
* - **Door opening indicator** — a short marker on the front face * - **Dashed X** — two diagonals across the shaft interior, the
* spanning `doorWidth` so users can see which way the doors open. * universal architectural mark for an elevator cab.
* - **Selection / target / queued chrome** — selection stroke when * - **Door opening** — two small jamb stubs flanking the opening at
* the elevator is selected, accent stroke when the runtime targets * the front face, with a dashed line spanning the opening (the
* this level (cab is travelling here) or this level is queued. * 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()` * Reads live overrides (`useLiveNodeOverrides`) and runtime cab state
* (inspector edits) and `useInteractive.getState().elevators[id]` * (`useInteractive.elevators[id]`) non-reactively; the registry layer
* (runtime cab travel). Those reads are non-reactive on their own — * subscribes to both stores so this builder re-runs on change.
* `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.
*/ */
const STAGE_LEVEL_FILTER_HIDE = true const STAGE_LEVEL_FILTER_HIDE = false
export function buildElevatorFloorplan( export function buildElevatorFloorplan(
node: ElevatorNode, node: ElevatorNode,
@@ -65,48 +58,33 @@ export function buildElevatorFloorplan(
const cabDepth = Math.max(display.depth, 0.8) const cabDepth = Math.max(display.depth, 0.8)
const shaftWidth = Math.max(display.shaftWidth ?? display.width, cabWidth, 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 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 doorWidth = Math.min(Math.max(display.doorWidth, 0.45), shaftWidth - 0.24)
const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness) const outerHalfW = Math.max(0.1, shaftWidth / 2 + wallThickness)
const halfDepth = Math.max(0.1, shaftDepth / 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 center = { x: display.position[0], y: display.position[2] }
const cos = Math.cos(display.rotation) const cos = Math.cos(display.rotation)
const sin = Math.sin(display.rotation) const sin = Math.sin(display.rotation)
const rotate = (lx: number, ly: number): [number, number] => { const rotate = (lx: number, ly: number): [number, number] => {
// Same clockwise convention as `rotatePlanVector` in editor — see // Negated-rotation matrix (equivalent to rotating by `-rotation`)
// `wiki/architecture/tools.md` for why every plan-space rotation // so SVG's CW-with-y-down `rotate` direction visually matches
// uses this matrix and not the standard counter-clockwise one. // 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] return [lx * cos + ly * sin, -lx * sin + ly * cos]
} }
const toPlan = (lx: number, ly: number): FloorplanPoint => {
// 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 [rx, ry] = rotate(lx, ly) const [rx, ry] = rotate(lx, ly)
return [center.x + rx, center.y + ry] return [center.x + rx, center.y + ry]
}) }
const rectPoints = (halfW: number, halfD: number): FloorplanPoint[] => [
// Cab inner rectangle. The cab sits flush against the front face toPlan(-halfW, -halfD),
// (-Z in local coords) so its center is `-shaftDepth/2 + cabDepth/2` toPlan(halfW, -halfD),
// away from shaft center. toPlan(halfW, halfD),
const cabCenterLocalY = -shaftDepth / 2 + cabDepth / 2 toPlan(-halfW, halfD),
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 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. // Runtime state — current level / target level / queued.
const runtime = useInteractive.getState().elevators[node.id] const runtime = useInteractive.getState().elevators[node.id]
@@ -122,63 +100,120 @@ export function buildElevatorFloorplan(
const isHighlighted = view?.highlighted ?? false const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted const showSelectedChrome = isSelected || isHighlighted
// Stroke selection — selected wins, then runtime target / queued // Base ink: near-black architectural outline. Selection switches to
// states get the accent palette colour so users can spot "the cab is // the palette accent. Runtime state (car-on-level, target, queued)
// coming here" at a glance. // is conveyed via the served-level chip column when selected, not
const stroke = // the main outline — so the floor-plan symbol stays neutral and
showSelectedChrome && palette // matches the line weight of the other architectural elements.
? palette.selectedStroke const baseInk = '#111111'
: isTargetLevel || isQueuedLevel const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseInk
? '#0ea5e9' const cabMarkInk = stroke
: '#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'
const children: FloorplanGeometry[] = [] 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({ children.push({
kind: 'polygon', kind: 'polygon',
points: outerPoints, points: rectPoints(outerHalfW, outerHalfD),
fill: shaftFill, 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, stroke,
strokeWidth: showSelectedChrome ? 0.04 : 0.03, strokeWidth: showSelectedChrome ? 0.035 : 0.025,
strokeLinejoin: 'round', strokeLinejoin: 'miter',
opacity: 0.85, strokeLinecap: 'butt',
}) })
// Cab inner rectangle. // Inner outline — closed rectangle, the inner face of the shaft.
// The X diagonals terminate at its corners.
children.push({ children.push({
kind: 'polygon', kind: 'polygon',
points: cabPoints, points: rectPoints(innerHalfW, innerHalfD),
fill: cabFill, fill: 'none',
fillOpacity: isCarOnLevel ? 0.85 : 0.55, stroke,
stroke: cabStroke, strokeWidth: 0.02,
strokeWidth: 0.018, strokeLinejoin: 'miter',
strokeLinejoin: 'round',
opacity: 0.92,
}) })
// Door opening indicator — a short line on the front edge centered // Dashed X across the shaft interior — corner-to-corner of the
// on the cab. The legacy renders a more complex slide / center-open // inner rectangle, the universal elevator-cab mark.
// hint; this is the minimum useful signal. const diagonals: Array<readonly [FloorplanPoint, FloorplanPoint]> = [
const doorY = -halfDepth [toPlan(-innerHalfW, -innerHalfD), toPlan(innerHalfW, innerHalfD)],
const [doorStartX, doorStartY] = rotate(-doorWidth / 2, doorY) [toPlan(innerHalfW, -innerHalfD), toPlan(-innerHalfW, innerHalfD)],
const [doorEndX, doorEndY] = rotate(doorWidth / 2, doorY) ]
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({ children.push({
kind: 'line', kind: 'line',
x1: center.x + doorStartX, x1: doorStart[0],
y1: center.y + doorStartY, y1: doorStart[1],
x2: center.x + doorEndX, x2: doorEnd[0],
y2: center.y + doorEndY, y2: doorEnd[1],
stroke: isCarOnLevel ? '#15803d' : '#0f172a', stroke: cabMarkInk,
strokeWidth: 0.05, strokeWidth: 0.02,
strokeLinecap: 'round', strokeDasharray: '0.08 0.06',
opacity: 0.92, strokeLinecap: 'butt',
opacity: 0.9,
}) })
// Served-level chips — vertical column of marker circles + level // Served-level chips — vertical column of marker circles + level
@@ -194,7 +229,7 @@ export function buildElevatorFloorplan(
const serviceOnlyLevelIds = new Set(display.serviceOnlyLevelIds ?? []) const serviceOnlyLevelIds = new Set(display.serviceOnlyLevelIds ?? [])
const rangeStep = 0.18 const rangeStep = 0.18
const rangeHeight = Math.max(0, (serviceLevelIds.length - 1) * rangeStep) 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 rangeX = center.x + rangeOffsetX
const rangeBottomY = center.y + rangeOffsetY + rangeHeight / 2 const rangeBottomY = center.y + rangeOffsetY + rangeHeight / 2
const rangeTopY = 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) { if (isSelected) {
children.push({ children.push({
kind: 'move-handle', kind: 'move-handle',
point: [display.position[0], display.position[2]], 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 } return { kind: 'group', children }
@@ -6,7 +6,12 @@ import {
useScene, useScene,
type WallNode, type WallNode,
} from '@pascal-app/core' } 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. * 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 * - **apply**: writes the fence + linked fence endpoints into the
* scene. Drag-session paused history captures originals; cascade * scene. Drag-session paused history captures originals; cascade
* resolver fans dirty marks through `endpoint-match`. * 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), * Performs the single-undo dance — revert to originals (snapshot),
* resume history, re-apply final draft — so the entire drag is one * resume history, re-apply final draft — so the entire drag is one
* `Ctrl-Z` step. Returns false to reject; `createDragSession.cancel` * `Ctrl-Z` step. Returns false to reject; `createDragSession.cancel`
@@ -143,13 +148,15 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
preview: (ctx, point, modifiers) => { preview: (ctx, point, modifiers) => {
const planPoint: FencePlanPoint = [point[0], point[1]] 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({ const snapped = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls: ctx.levelWalls, walls: ctx.levelWalls,
fences: ctx.levelFences, fences: ctx.levelFences,
start: ctx.fixedPoint,
angleSnap: !modifiers.shift,
ignoreFenceIds: [ctx.fenceId as string], ignoreFenceIds: [ctx.fenceId as string],
step: modifiers.shift ? WALL_FINE_GRID_STEP : undefined,
}) })
const nextStart = ctx.endpoint === 'start' ? snapped : ctx.fixedPoint const nextStart = ctx.endpoint === 'start' ? snapped : ctx.fixedPoint
const nextEnd = ctx.endpoint === 'end' ? 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 // fence/actions/curve.ts for the rationale (no-op drag must still
// push a pastState entry to avoid Ctrl-Z cancelling the fence // push a pastState entry to avoid Ctrl-Z cancelling the fence
// creation that preceded the activation). // 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 // Single-undo dance: revert to originals (paused history → no
// zundo record), resume history, then re-apply the final draft // zundo record), resume history, then re-apply the final draft
+3 -3
View File
@@ -16,7 +16,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
getWallGridStep, getSegmentGridStep,
markToolCancelConsumed, markToolCancelConsumed,
snapScalarToGrid, snapScalarToGrid,
triggerSFX, triggerSFX,
@@ -32,7 +32,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
* fence/curve-fence-tool.tsx). Same snap pipeline, same Shift override, * fence/curve-fence-tool.tsx). Same snap pipeline, same Shift override,
* same history dance, same activation grace. Imports adjusted to the * same history dance, same activation grace. Imports adjusted to the
* `@pascal-app/editor` public surface (triggerSFX, markToolCancelConsumed, * `@pascal-app/editor` public surface (triggerSFX, markToolCancelConsumed,
* getWallGridStep, snapScalarToGrid). Mounted via * getSegmentGridStep, snapScalarToGrid). Mounted via
* `def.affordanceTools.curve` — ToolManager picks it up at runtime, * `def.affordanceTools.curve` — ToolManager picks it up at runtime,
* legacy fallback is unused when this kind is registered. * 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 onGridMove = (event: GridEvent) => {
const snapStep = getWallGridStep() const snapStep = getSegmentGridStep()
const localX = shiftPressedRef.current const localX = shiftPressedRef.current
? event.localPosition[0] ? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep) : 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 { buildFenceFloorplan } from './floorplan'
import { fenceMoveEndpointAffordance } from './floorplan-affordances' import { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances'
import { fenceFloorplanMoveTarget } from './floorplan-move'
import { buildFenceGeometry } from './geometry' import { buildFenceGeometry } from './geometry'
import { fenceParametrics } from './parametrics' import { fenceParametrics } from './parametrics'
import { FenceNode } from './schema' 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 * Fence — Phase 5 batch kind. Stage B complete: `def.geometry` drives
* the rebuild via the generic `<GeometrySystem>`; `<ParametricNodeRenderer>` * the rebuild via the generic `<GeometrySystem>`; `<ParametricNodeRenderer>`
@@ -59,6 +172,7 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
}, },
parametrics: fenceParametrics, parametrics: fenceParametrics,
handles: fenceHandles,
// Stage D: kind-owned placement tool. Two-click flow (start → end) // Stage D: kind-owned placement tool. Two-click flow (start → end)
// with live preview, length / angle HUD, snap to walls / fences / // 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 // Legacy `floorplanFenceEntries` short-circuits to [] when fence is
// registered (see floorplan-panel.tsx). // registered (see floorplan-panel.tsx).
floorplan: buildFenceFloorplan, floorplan: buildFenceFloorplan,
// 2D drag affordance — sister to `actions/move-endpoint.ts`. The 3D // 2D drag affordances — sister to `actions/move-endpoint.ts`. The 3D
// DragAction drives R3F grid events through `createDragSession`; this // DragAction drives R3F grid events through `createDragSession`; these
// one drives SVG pointer events through the floor-plan registry // drive SVG pointer events through the floor-plan registry
// dispatcher's snapshot + single-undo dance. Same legacy semantics. // 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: { floorplanAffordances: {
'move-endpoint': fenceMoveEndpointAffordance, '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. // Stage D — all four fence drag-affordances live in this folder.
// curve / move-endpoint / move are 1:1 ports of the legacy tools // curve / move-endpoint / move are 1:1 ports of the legacy tools
// (same snap pipeline, same history dance, same cursor render), // (same snap pipeline, same history dance, same cursor render),
@@ -4,10 +4,21 @@ import {
type FenceNode, type FenceNode,
type FloorplanAffordance, type FloorplanAffordance,
type FloorplanAffordanceSession, type FloorplanAffordanceSession,
getMaxWallCurveOffset,
getWallChordFrame,
normalizeWallCurveOffset,
useLiveNodeOverrides,
useScene, useScene,
type WallNode, type WallNode,
} from '@pascal-app/core' } 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 * Floor-plan 2D drag affordances for fence — sister to the 3D
@@ -70,6 +81,57 @@ function collectLinkedFences(
return out 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> = { export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
start({ node, payload, nodes }): FloorplanAffordanceSession { start({ node, payload, nodes }): FloorplanAffordanceSession {
const { endpoint } = payload as FenceEndpointPayload const { endpoint } = payload as FenceEndpointPayload
@@ -92,13 +154,15 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
// itself is excluded via `ignoreFenceIds`). // itself is excluded via `ignoreFenceIds`).
const sceneNodes = useScene.getState().nodes const sceneNodes = useScene.getState().nodes
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId) 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({ const snapped = snapFenceDraftPoint({
point: planPoint as FencePlanPoint, point: planPoint as FencePlanPoint,
walls: nextWalls, walls: nextWalls,
fences: nextFences, fences: nextFences,
start: fixedPoint,
angleSnap: !modifiers.shiftKey,
ignoreFenceIds: [node.id], ignoreFenceIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
}) })
const nextStart = endpoint === 'start' ? snapped : fixedPoint const nextStart = endpoint === 'start' ? snapped : fixedPoint
const nextEnd = endpoint === 'end' ? snapped : fixedPoint const nextEnd = endpoint === 'end' ? snapped : fixedPoint
@@ -124,7 +188,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
return ( return (
!!finalFence && !!finalFence &&
finalFence.type === 'fence' && 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, type GeometryContext,
getWallCurveFrameAt, getWallCurveFrameAt,
getWallCurveLength, getWallCurveLength,
getWallMidpointHandlePoint,
isCurvedWall, isCurvedWall,
sampleWallCenterline, sampleWallCenterline,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -339,7 +340,10 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
cursor: 'pointer', 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) { if (isSelected) {
children.push({ children.push({
kind: 'endpoint-handle', kind: 'endpoint-handle',
@@ -356,6 +360,50 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
payload: { fenceId: node.id, endpoint: 'end' as const }, 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) const length = getWallCurveLength(node)
if (length >= 0.1) { if (length >= 0.1) {
const midX = (node.start[0] + node.end[0]) / 2 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] }) useViewer.getState().setSelection({ selectedIds: [fenceId] })
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
markToolCancelConsumed() 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() exitMoveMode()
} }
@@ -275,7 +279,22 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
return () => { return () => {
if (!wasCommitted) { 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() useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
+14 -6
View File
@@ -25,6 +25,7 @@ import {
type SegmentAngleReference, type SegmentAngleReference,
snapFenceDraftPoint, snapFenceDraftPoint,
triggerSFX, triggerSFX,
WALL_FINE_GRID_STEP,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
@@ -437,14 +438,16 @@ export const FenceTool: React.FC = () => {
if (!(cursorRef.current && previewRef.current)) return if (!(cursorRef.current && previewRef.current)) return
const { walls, fences } = getCurrentLevelElements() const { walls, fences } = getCurrentLevelElements()
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] 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) { if (buildingState.current === 1) {
const snappedLocal = snapFenceDraftPoint({ const snappedLocal = snapFenceDraftPoint({
point: localPoint, point: localPoint,
walls, walls,
fences, fences,
start: [startingPoint.current.x, startingPoint.current.z], step,
angleSnap: !shiftPressed.current,
}) })
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current) cursorRef.current.position.copy(endingPoint.current)
@@ -467,7 +470,7 @@ export const FenceTool: React.FC = () => {
), ),
) )
} else { } 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]) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
setDraftMeasurement(null) setDraftMeasurement(null)
} }
@@ -481,9 +484,15 @@ export const FenceTool: React.FC = () => {
const { walls, fences } = getCurrentLevelElements() const { walls, fences } = getCurrentLevelElements()
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
if (buildingState.current === 0) { 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]) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
buildingState.current = 1 buildingState.current = 1
@@ -494,8 +503,7 @@ export const FenceTool: React.FC = () => {
point: localClick, point: localClick,
walls, walls,
fences, fences,
start: [startingPoint.current.x, startingPoint.current.z], step: clickStep,
angleSnap: !shiftPressed.current,
}) })
const dx = snappedEnd[0] - startingPoint.current.x const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z const dz = snappedEnd[1] - startingPoint.current.z
+23 -7
View File
@@ -3,8 +3,8 @@
import { type GuideNode, useRegistry } from '@pascal-app/core' import { type GuideNode, useRegistry } from '@pascal-app/core'
import { useAssetUrl, useViewer } from '@pascal-app/viewer' import { useAssetUrl, useViewer } from '@pascal-app/viewer'
import { useLoader } from '@react-three/fiber' import { useLoader } from '@react-three/fiber'
import { Suspense, useMemo, useRef } from 'react' import { Suspense, useEffect, useMemo, useRef } from 'react'
import { DoubleSide, type Group, type Texture, TextureLoader } from 'three' import { DoubleSide, type Group, PlaneGeometry, type Texture, TextureLoader } from 'three'
import { float, texture } from 'three/tsl' import { float, texture } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu' 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 GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opacity: number }) => {
const tex = useLoader(TextureLoader, url) as Texture 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 img = tex.image as HTMLImageElement | ImageBitmap
const w = img.width || 1 const w = img.width || 1
const h = img.height || 1 const h = img.height || 1
@@ -54,18 +60,28 @@ const GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opaci
depthWrite: false, 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]) }, [tex, scale, opacity])
useEffect(
() => () => {
geometry.dispose()
material.dispose()
},
[geometry, material],
)
return ( return (
<mesh <mesh
frustumCulled={false} frustumCulled={false}
geometry={geometry}
material={material} material={material}
raycast={() => {}} raycast={() => {}}
rotation={[-Math.PI / 2, 0, 0]} 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 { buildRoofSegmentFloorplan } from './floorplan'
import { roofSegmentParametrics } from './parametrics' import { roofSegmentParametrics } from './parametrics'
import { RoofSegmentNode } from './schema' 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 * Roof segment — Stage A. Child of a roof node, owns the per-segment
* polygon + pitch. Geometry is generated by `RoofSystem` (registered * polygon + pitch. Geometry is generated by `RoofSystem` (registered
@@ -27,17 +200,29 @@ export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
capabilities: { capabilities: {
selectable: { hitVolume: 'bbox' }, selectable: { hitVolume: 'bbox' },
duplicable: false, duplicable: true,
deletable: true, deletable: true,
}, },
parametrics: roofSegmentParametrics, parametrics: roofSegmentParametrics,
handles: roofSegmentHandles,
renderer: { renderer: {
kind: 'parametric', kind: 'parametric',
module: () => import('./renderer'), module: () => import('./renderer'),
}, },
floorplan: buildRoofSegmentFloorplan, 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: { presentation: {
label: 'Roof Segment', 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 const roof = ctx.parent as RoofNode | null
if (!roof || roof.type !== 'roof') return null if (!roof || roof.type !== 'roof') return null
// Segment center in world coords: parent roof's transform applied to // Segment center in world coords. Floor-plan plots at `-rotation` so
// the segment's local position offset. // SVG's CW-with-y-down `rotate` direction ends up matching Three.js
const cosRoof = Math.cos(roof.rotation) // Y-rotation (CCW from top-down). The standard math rotation matrix
const sinRoof = Math.sin(roof.rotation) // 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 localX = node.position[0]
const localZ = node.position[2] const localZ = node.position[2]
const cx = roof.position[0] + localX * cosRoof - localZ * sinRoof const cx = roof.position[0] + localX * cosRoof - localZ * sinRoof
const cz = roof.position[2] + localX * sinRoof + localZ * cosRoof 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 cos = Math.cos(rotation)
const sin = Math.sin(rotation) const sin = Math.sin(rotation)
const halfWidth = node.width / 2 const halfWidth = node.width / 2
@@ -54,23 +58,40 @@ export function buildRoofSegmentFloorplan(
const isHighlighted = view?.highlighted ?? false const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted const showSelectedChrome = isSelected || isHighlighted
const stroke = // Black architectural outline by default; palette accent on select.
showSelectedChrome && palette ? palette.selectedStroke : 'rgba(125, 211, 252, 0.82)' // Mirrors the elevator / column style so all structural elements read
const fill = showSelectedChrome ? '#fed7aa' : 'rgba(56, 189, 248, 0.16)' // the same in the floor plan.
const baseInk = '#111111'
const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseInk
const children: FloorplanGeometry[] = [ 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', kind: 'polygon',
points, 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, stroke,
strokeWidth: showSelectedChrome ? 0.04 : 0.025, strokeWidth: showSelectedChrome ? 0.035 : 0.025,
strokeLinejoin: 'round', strokeLinejoin: 'miter',
opacity: 0.85,
}, },
] ]
// 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') { if (node.roofType !== 'flat') {
const ridgeAxis = const ridgeAxis =
node.roofType === 'gable' || node.roofType === 'gambrel' node.roofType === 'gable' || node.roofType === 'gambrel'
@@ -88,18 +109,71 @@ export function buildRoofSegmentFloorplan(
y1: cz - halfSpan * Math.sin(axisAngle), y1: cz - halfSpan * Math.sin(axisAngle),
x2: cx + halfSpan * Math.cos(axisAngle), x2: cx + halfSpan * Math.cos(axisAngle),
y2: cz + halfSpan * Math.sin(axisAngle), y2: cz + halfSpan * Math.sin(axisAngle),
stroke: showSelectedChrome ? '#eff6ff' : 'rgba(186, 230, 253, 0.84)', stroke,
strokeWidth: 1.4, strokeWidth: 0.02,
strokeLinecap: 'round', strokeDasharray: '0.1 0.08',
vectorEffect: 'non-scaling-stroke', 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) { if (isSelected) {
children.push({ children.push({
kind: 'move-handle', kind: 'move-handle',
point: [cx, cz], 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 } 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 { buildShelfFloorplan } from './floorplan'
import { shelfFloorplanMoveTarget } from './floorplan-move' import { shelfFloorplanMoveTarget } from './floorplan-move'
import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry' import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry'
import { shelfParametrics } from './parametrics' import { shelfParametrics } from './parametrics'
import { ShelfNode } from './schema' 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> = { export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
kind: 'shelf', kind: 'shelf',
schemaVersion: 2, schemaVersion: 2,
@@ -82,6 +180,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
}, },
parametrics: shelfParametrics, parametrics: shelfParametrics,
handles: shelfHandles,
// Three-checkbox composition: shelf needs only pure builder functions. // Three-checkbox composition: shelf needs only pure builder functions.
// The framework's <ParametricNodeRenderer> + <GeometrySystem> handle 3D // 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 // transforms during drag for real-time 3D sync and commits via a
// single tracked `updateNode`. // single tracked `updateNode`.
floorplanMoveTarget: shelfFloorplanMoveTarget, 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'), preview: () => import('./preview'),
tool: () => import('./tool'), 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' 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 * 2D floor-plan representation of a shelf. The unit's outer footprint
* projects to a rectangle of `width × depth` centered on the shelf's * 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 * stack vertically under the topmost board from a top-down view and
* adding them clutters the plan without conveying useful information. * adding them clutters the plan without conveying useful information.
* *
* Coordinates are level-local meters; the floor-plan panel applies the * When selected, emits resize chevrons matching the 3D handle set
* world→SVG transform via its viewBox. Rotation is radians (three.js * (width on +X, depth on +Z) plus a rotate-arrow at the front-right
* convention); the renderer converts to SVG degrees. * 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 [px, , pz] = node.position
const ry = node.rotation[1] ?? 0 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 halfW = node.width / 2
const halfD = node.depth / 2 const halfD = node.depth / 2
const isSelected = ctx?.viewState?.selected ?? false
// Floor-plan fill: a single neutral fill regardless of `material`. // Floor-plan fill: a single neutral fill regardless of `material`.
// 2D doesn't render the actual paint material — surfaces in plan view // 2D doesn't render the actual paint material — surfaces in plan view
// read as outline + tone, not photoreal texture. Using a fixed light // read as outline + tone, not photoreal texture.
// gray keeps the plan visually consistent with the other furniture const footprintChildren: FloorplanGeometry[] = [
// kinds (item / column / etc.) which also render as neutral fills.
const children: FloorplanGeometry[] = [
{ {
kind: 'rect', kind: 'rect',
x: -halfW, x: -halfW,
@@ -48,7 +63,7 @@ export function buildShelfFloorplan(node: ShelfNode): FloorplanGeometry {
const colStep = innerWidth / node.columns const colStep = innerWidth / node.columns
for (let c = 1; c < node.columns; c++) { for (let c = 1; c < node.columns; c++) {
const x = -innerWidth / 2 + c * colStep const x = -innerWidth / 2 + c * colStep
children.push({ footprintChildren.push({
kind: 'line', kind: 'line',
x1: x, x1: x,
y1: -halfD + node.thickness, y1: -halfD + node.thickness,
@@ -61,9 +76,68 @@ export function buildShelfFloorplan(node: ShelfNode): FloorplanGeometry {
} }
} }
return { const footprintGroup: FloorplanGeometry = {
kind: 'group', kind: 'group',
transform: { translate: [px, pz], rotate: ry }, transform: { translate: [px, pz], rotate: planRy },
children, 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} width={300}
> >
<PanelSection title="Type"> <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) => { {SKYLIGHT_TYPE_ORDER.map((skylightType) => {
const isSelected = activeSkylightType === skylightType const isSelected = activeSkylightType === skylightType
return ( return (
<button <button
className={cn( 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 isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground' ? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover: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' '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 { PolygonEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react' import { useCallback, useEffect } from 'react'
/** /**
* Phase 5 Stage D — slab boundary editor (registry-driven). * Phase 5 Stage D — slab boundary editor (registry-driven).
@@ -14,10 +14,19 @@ import { useCallback } from 'react'
* bracketing — lives in `PolygonEditor` itself. * bracketing — lives in `PolygonEditor` itself.
* *
* Mounted by ToolManager via `def.affordanceTools['boundary-edit']`. * 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 }) => { export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabId }) => {
const slabNode = useScene((s) => s.nodes[slabId]) const slabNode = useScene((s) => s.nodes[slabId])
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const markDirty = useScene((s) => s.markDirty)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null 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], [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 if (!slab?.polygon || slab.polygon.length < 3) return null
return ( return (
@@ -39,6 +72,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
levelId={resolveLevelId(slab, useScene.getState().nodes)} levelId={resolveLevelId(slab, useScene.getState().nodes)}
minVertices={3} minVertices={3}
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
polygon={slab.polygon} polygon={slab.polygon}
surfaceHeight={slab.elevation ?? 0.05} 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 { buildSlabFloorplan } from './floorplan'
import { import {
slabAddVertexAffordance, slabAddVertexAffordance,
@@ -10,6 +10,48 @@ import { buildSlabGeometry } from './geometry'
import { slabParametrics } from './parametrics' import { slabParametrics } from './parametrics'
import { SlabNode } from './schema' 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` * Slab — Phase 5 batch kind, polygon-based. Stage B: `def.geometry`
* drives the rebuild via generic <GeometrySystem>; <ParametricNodeRenderer> * drives the rebuild via generic <GeometrySystem>; <ParametricNodeRenderer>
@@ -60,6 +102,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
}, },
parametrics: slabParametrics, parametrics: slabParametrics,
handles: slabHandles,
// Stage D: kind-owned placement tool. Multi-click polygon drawing // Stage D: kind-owned placement tool. Multi-click polygon drawing
// with axis/45° snap (Shift to defeat). // with axis/45° snap (Shift to defeat).
+29 -2
View File
@@ -1,9 +1,9 @@
'use client' '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 { PolygonEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react' import { useCallback, useEffect } from 'react'
/** /**
* Phase 5 Stage D — slab hole editor (registry-driven). * 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 slabNode = useScene((s) => s.nodes[slabId])
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const markDirty = useScene((s) => s.markDirty)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null 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], [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 if (!(slab && hole) || hole.length < 3) return null
return ( return (
@@ -44,6 +70,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
levelId={resolveLevelId(slab, useScene.getState().nodes)} levelId={resolveLevelId(slab, useScene.getState().nodes)}
minVertices={3} minVertices={3}
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
polygon={hole} polygon={hole}
surfaceHeight={slab.elevation ?? 0.05} surfaceHeight={slab.elevation ?? 0.05}
/> />
@@ -87,6 +87,75 @@ describe('getAnalyticalNormal', () => {
expect(n.z).toBeGreaterThan(0) expect(n.z).toBeGreaterThan(0)
expect(n.y).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', () => { 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 * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshStandardNodeMaterial } from 'three/webgpu' import { MeshStandardNodeMaterial } from 'three/webgpu'
@@ -217,35 +223,97 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe
return peakY - t * rh 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 { export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 {
const { roofType, depth, width } = seg const { roofType, depth, width } = seg
const rh = getActiveRoofHeight(seg) const slope = getSegmentSlopeFrame(seg)
if (rh === 0) return new THREE.Vector3(0, 1, 0) if (slope.activeRh === 0 || slope.tanTheta === 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 primaryTan = slope.tanTheta
const halfW = width / 2
const halfD = depth / 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 ─────────────────────────────────────────────── // ─── 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 { stairSegmentParametrics } from './parametrics'
import { StairSegmentNode } from './schema' 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. * Stair segment — Stage A. Child of a stair node; per-flight geometry.
* Built by `StairSystem` registered on the parent stair definition. * Built by `StairSystem` registered on the parent stair definition.
@@ -29,6 +123,7 @@ export const stairSegmentDefinition: NodeDefinition<typeof StairSegmentNode> = {
}, },
parametrics: stairSegmentParametrics, parametrics: stairSegmentParametrics,
handles: stairSegmentHandles,
renderer: { renderer: {
kind: 'parametric', kind: 'parametric',
+19 -27
View File
@@ -16,6 +16,7 @@ import {
PanelWrapper, PanelWrapper,
SegmentedControl, SegmentedControl,
SliderControl, SliderControl,
ToggleControl,
triggerSFX, triggerSFX,
useEditor, useEditor,
} from '@pascal-app/editor' } from '@pascal-app/editor'
@@ -204,34 +205,25 @@ export default function StairSegmentPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Structure"> <PanelSection title="Structure">
<div className="flex items-center justify-between px-1 py-1"> <div className="space-y-3">
<span className="text-muted-foreground text-xs">Fill to floor</span> <ToggleControl
<button checked={node.fillToFloor}
className={`relative h-5 w-10 rounded-full transition-colors ${ label="Fill to floor"
node.fillToFloor ? 'bg-blue-500' : 'bg-[#3e3e3e]' onChange={(checked) => handleUpdate({ fillToFloor: checked })}
}`}
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}
/> />
)} {!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>
<PanelSection title="Position"> <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 { buildStairFloorplan } from './floorplan'
import { stairFloorplanMoveTarget } from './floorplan-move'
import { stairParametrics } from './parametrics' import { stairParametrics } from './parametrics'
import { StairNode } from './schema' import { StairNode } from './schema'
@@ -28,6 +326,7 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
}, },
parametrics: stairParametrics, parametrics: stairParametrics,
handles: stairHandles,
renderer: { renderer: {
kind: 'parametric', kind: 'parametric',
@@ -44,6 +343,26 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
// compute their own polygon in isolation. See // compute their own polygon in isolation. See
// `nodes/src/stair/floorplan.ts` for the emitter. // `nodes/src/stair/floorplan.ts` for the emitter.
floorplan: buildStairFloorplan, 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: { presentation: {
label: 'Stair', 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, StairNode,
StairSegmentNode, StairSegmentNode,
} from '@pascal-app/core' } 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 { import {
buildFloorplanStairEntry, buildFloorplanStairEntry,
buildSvgAnnularSectorPath, buildSvgAnnularSectorPath,
@@ -107,6 +113,79 @@ export function buildStairFloorplan(
opacity: showSelectedChrome ? 0.88 : 0.6, 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 { } else {
// Curved / spiral — full arc-band chrome. Mirrors the legacy // Curved / spiral — full arc-band chrome. Mirrors the legacy
@@ -116,7 +195,19 @@ export function buildStairFloorplan(
const sectorStartAngle = -stair.rotation - normalizedSweepAngle / 2 const sectorStartAngle = -stair.rotation - normalizedSweepAngle / 2
const sectorEndAngle = sectorStartAngle + normalizedSweepAngle const sectorEndAngle = sectorStartAngle + normalizedSweepAngle
const spiralLandingSweep = getFloorplanSpiralLandingSweep(stair, 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 stairCenter = { x: stair.position[0], y: stair.position[2] }
const innerRadius = Math.max( const innerRadius = Math.max(
stairType === 'spiral' ? 0.05 : 0.2, stairType === 'spiral' ? 0.05 : 0.2,
@@ -125,6 +216,13 @@ export function buildStairFloorplan(
const outerRadius = innerRadius + stair.width const outerRadius = innerRadius + stair.width
const centerlineRadius = innerRadius + stair.width / 2 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. // 1. Annular sector — the filled shaft footprint.
children.push({ children.push({
kind: 'path', kind: 'path',
@@ -147,7 +245,7 @@ export function buildStairFloorplan(
d: buildSvgArcPath(stairCenter, outerRadius, sectorStartAngle, visualSectorEndAngle), d: buildSvgArcPath(stairCenter, outerRadius, sectorStartAngle, visualSectorEndAngle),
fill: 'none', fill: 'none',
stroke: stairStroke, stroke: stairStroke,
strokeWidth: showSelectedChrome ? 0.026 : 0.022, strokeWidth: outerArcWidth,
vectorEffect: 'non-scaling-stroke', vectorEffect: 'non-scaling-stroke',
}) })
children.push({ children.push({
@@ -155,7 +253,7 @@ export function buildStairFloorplan(
d: buildSvgArcPath(stairCenter, innerRadius, sectorStartAngle, visualSectorEndAngle), d: buildSvgArcPath(stairCenter, innerRadius, sectorStartAngle, visualSectorEndAngle),
fill: 'none', fill: 'none',
stroke: stairStroke, stroke: stairStroke,
strokeWidth: showSelectedChrome ? 0.022 : 0.018, strokeWidth: innerArcWidth,
vectorEffect: 'non-scaling-stroke', vectorEffect: 'non-scaling-stroke',
}) })
@@ -171,14 +269,29 @@ export function buildStairFloorplan(
const inner = getArcPlanPoint(stairCenter, innerRadius, angle) const inner = getArcPlanPoint(stairCenter, innerRadius, angle)
const outer = getArcPlanPoint(stairCenter, outerRadius, angle) const outer = getArcPlanPoint(stairCenter, outerRadius, angle)
const isLast = index === stepCount 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({ children.push({
kind: 'line', kind: 'line',
x1: inner.x, x1: inner.x,
y1: inner.y, y1: inner.y,
x2: outer.x, x2: outer.x,
y2: outer.y, y2: outer.y,
stroke: isLast ? stairAccent : stairStroke, stroke: stairType === 'spiral' && isLast ? stairAccent : stairStroke,
strokeWidth: isLast ? 0.026 : 0.018, strokeWidth: stepWidth,
strokeDasharray: index >= dashedFromIndex && !isLast ? '0.1 0.08' : undefined, strokeDasharray: index >= dashedFromIndex && !isLast ? '0.1 0.08' : undefined,
vectorEffect: 'non-scaling-stroke', vectorEffect: 'non-scaling-stroke',
}) })
@@ -199,7 +312,7 @@ export function buildStairFloorplan(
fill: 'none', fill: 'none',
stroke: stairAccent, stroke: stairAccent,
strokeDasharray: '0.08 0.11', strokeDasharray: '0.08 0.11',
strokeWidth: 0.018, strokeWidth: 1.1,
vectorEffect: 'non-scaling-stroke', vectorEffect: 'non-scaling-stroke',
}) })
} }
@@ -214,7 +327,7 @@ export function buildStairFloorplan(
r: Math.max(innerRadius * 0.18, 0.06), r: Math.max(innerRadius * 0.18, 0.06),
fill, fill,
stroke: stairAccent, stroke: stairAccent,
strokeWidth: 0.018, strokeWidth: 1.2,
vectorEffect: 'non-scaling-stroke', vectorEffect: 'non-scaling-stroke',
}) })
} }
@@ -231,12 +344,70 @@ export function buildStairFloorplan(
fill: stairAccent, fill: stairAccent,
stroke: 'none', 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 // Direction arrow — emitted by `buildFloorplanStairEntry` as a polyline
// (the spine) plus a polygon (the head). Tells the user which way // (the spine) plus a polygon (the head). Tells the user which way
// "up" is at a glance. // "up" is at a glance. Skip for curved / spiral: those already draw
if (entry.arrow) { // 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) { if (entry.arrow.polyline.length >= 2) {
children.push({ children.push({
kind: 'polyline', 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 // Move handle — orange dot at the stair root position. Same UX as
// every other kind's `move-handle`: click to enter cursor-follow // every other kind's `move-handle`: click to enter cursor-follow
// mode, click again to commit. // mode, click again to commit.
+121 -36
View File
@@ -4,6 +4,7 @@ import {
type AnyNodeId, type AnyNodeId,
type StairNode, type StairNode,
type StairSegmentNode, type StairSegmentNode,
useLiveNodeOverrides,
useRegistry, useRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -52,8 +53,18 @@ type LandingChainNextStair = {
isTerminalLandingBeforeStair: boolean isTerminalLandingBeforeStair: boolean
} }
export const StairRenderer = ({ node }: { node: StairNode }) => { export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
const ref = useRef<THREE.Group>(null!) 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' const isSegmentBasedStair = node.stairType === 'straight'
useRegistry(node.id, 'stair', ref) useRegistry(node.id, 'stair', ref)
@@ -464,17 +475,11 @@ function CurvedStairBody({
return ( return (
<group name={isSpiral ? 'spiral-stair' : 'curved-stair'}> <group name={isSpiral ? 'spiral-stair' : 'curved-stair'}>
{isSpiral && (stair.showCenterColumn ?? true) ? ( {isSpiral && (stair.showCenterColumn ?? true) ? (
<mesh <SpiralColumnMesh
castShadow height={spiralColumnHeight}
material={sideMaterial} material={sideMaterial}
name="stair-side" radius={spiralColumnRadius}
position={[0, spiralColumnHeight / 2, 0]} />
receiveShadow
>
<cylinderGeometry
args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]}
/>
</mesh>
) : null} ) : null}
{Array.from({ length: stepCount }).map((_, index) => { {Array.from({ length: stepCount }).map((_, index) => {
const currentHeight = stepHeight * (index + 1) const currentHeight = stepHeight * (index + 1)
@@ -498,32 +503,13 @@ function CurvedStairBody({
position-y={stepY} position-y={stepY}
> >
{isSpiral && (stair.showStepSupports ?? true) ? ( {isSpiral && (stair.showStepSupports ?? true) ? (
<mesh <SpiralStepSupportMesh
castShadow innerRadius={innerRadius}
material={sideMaterial} material={sideMaterial}
name="stair-side" midAngle={midAngle}
position={[ spiralColumnRadius={spiralColumnRadius}
Math.cos(midAngle) * thickness={thickness}
(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>
) : null} ) : null}
<CurvedStepMesh <CurvedStepMesh
endAngle={endAngle} endAngle={endAngle}
@@ -585,11 +571,110 @@ function CurvedStepMesh({
[endAngle, innerRadius, outerRadius, startAngle, stepHeight, thickness], [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 ( return (
<mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow /> <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( function buildCurvedStepGeometry(
innerRadius: number, innerRadius: number,
outerRadius: number, outerRadius: number,
+2 -2
View File
@@ -14,7 +14,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
getWallGridStep, getSegmentGridStep,
markToolCancelConsumed, markToolCancelConsumed,
snapScalarToGrid, snapScalarToGrid,
triggerSFX, triggerSFX,
@@ -84,7 +84,7 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const snapStep = getWallGridStep() const snapStep = getSegmentGridStep()
const localX = shiftPressedRef.current const localX = shiftPressedRef.current
? event.localPosition[0] ? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep) : snapScalarToGrid(event.localPosition[0], snapStep)

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