Two follow-ups to the plate-recess fix:
- Top surface: full-height frame members that pass UNDER the top board
(back panel, bookshelf column dividers, corner posts) had their top face at
unitHeight — coplanar with the top board's top — so they z-fought through it
(the seam on the top). Drop their top 1mm (cappedFrameY); bottoms stay on the
floor so nothing floats.
- Plate width: the previous pass recessed every board's width, which opened a
1mm gap where boards ABUT the side panels (cubby / bookshelf-with-sides).
Width is now recessed only where boards span OVER posts (open-rack /
no-sides bookshelf); abutting boards keep full width and meet the sides
flush. Depth stays recessed everywhere (gap-free, fixes the back-panel fight).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Open-rack / no-sides shelves span their boards across the full footprint, so a
board's end / front / back faces land exactly on the corner posts' outer faces
(and, with a back panel, on its rear face). Coplanar surfaces the depth buffer
can't order flicker as z-fighting (visible as seams where plates meet posts).
Recess every board 1mm on width + depth via a `boardGeometry` helper so each
plate sits just inside the frame — the meshes still overlap (no visible gap),
but no two faces are coplanar. Frame parts (posts/sides/back/dividers/brackets)
keep their full size as the silhouette.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proves the unified (nodeId, slotId) slot contract on a procedural generator,
beyond items and walls. A shelf now exposes three paintable slots — shelves /
frame / back — painted through the same PaintCapability dispatch and the same
node.slots: Record<slotId, MaterialRef> shape items use.
Foundation (shared, reusable by future procedural kinds):
- core: SlotDeclaration type + capabilities.slots(node) registry declaration;
GeometryContext gains `materials` so a pure builder can resolve scene:<id>
slot refs without importing useScene.
- viewer GeometrySystem: threads the scene material library into every builder
ctx, and re-dirties (bypassing the geometryKey skip) any geometry node that
references a scene material when that material changes — so editing a custom
colour propagates to every shelf using it, matching items.
Shelf:
- schema: slots: Record<string, MaterialRef> (mirrors ItemNode).
- geometry: per-slot material resolution (slot override -> legacy whole-shelf
-> declared default colour); every mesh stamped with userData.slotId;
DEFAULT_SHELF_MATERIAL retired (declared default gives identical off-white).
- paint.ts: PaintCapability (resolveRole from userData.slotId, scene-material
commit for one-off colours, preview restricted to __fromGeometry meshes so
hosted items aren't ghosted, getEffectiveMaterial incl. legacy fallback).
- definition: paint + slots capabilities; slots folded into geometryKey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round 8 of the opening-placement UX work. Make door/window placement feel
physical and predictable, and unify the validity/placement logic behind one
shared decision.
UX:
- Window default sill 0.5 m (DEFAULT_WINDOW_SILL_M) so fresh windows float
slightly above the floor; existing windows keep their own sill.
- Dev-only floor "shadow" projection for windows during placement/move
(footprint + dashed drop-line) so an elevated window's plan spot is legible.
- Move SFX: one soft grid-snap click per grid step — identical free-following
over floor or sliding on a wall (keyed on the raw cursor; per-frame + step
dedup), no separate snap cue (that was a "double"). Mirrored into the 2D
floorplan-move so 2D and 3D match.
- Shift = force-place over a collision (commit allowed; ghost stays a red
warning) + free-place (lands at the raw cursor but keeps the alignment guides
visible). Tint flips green/red live when Shift is pressed/released stationary.
- On-wall preview is now the tinted ghost (green placeable / red colliding),
matching the free-follow ghost, instead of a pale solid mesh + thin wireframe.
- R-flip fixes: always toggles (no initial no-op needing a second press),
e.repeat filtered, ghost rebuilds with the live `side`, and the ghost's
on-wall world yaw uses `itemRotation - wallAngle` so it faces exactly what
commit places (cursorRotation was π off for the asymmetric ghost). R ownership
follows the current pointer pane (capture-phase + stopImmediatePropagation in
the 2D overlay) so 3D and 2D never double-flip or go dead.
Refactor / quality:
- New `resolveOpeningPlacement({collides,forcePlace}) -> {placeable,tint}` in
shared/wall-attach-target.ts — the single source of truth the ghost tint AND
the commit gates both consume, so they can't disagree under Shift.
- Consolidated the byte-identical `hasWallChildOverlap` into one shared impl
(door-math/window-math re-export it).
- applyGhost gained a green "valid" tint.
- Removed dead `cursorRotation` from the move-tool targets after the yaw fix.
Docs: "2D <-> 3D behavioral parity" principle in wiki/architecture/tools.md
(+ README + AGENTS.md) — applicable behaviors must exist in both views.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pool slabs (generatePoolGeometry) were the one procedural surface emitting
normalized [0..1] floor UVs, so a textured finish stretched to fit the pool
instead of tiling at real-world scale like every other surface. Switch the
floor to shape-space metres (x, -z) — the same mapping generatePositiveSlab-
Geometry already uses for its caps. Pool walls were already in metres.
Also document the contract in wiki/architecture/materials-and-themes.md: every
procedural surface generates UVs in metres (1 UV unit = 1 m), GLB slots follow
the same ~1 unit/m authoring convention, and a catalog material's `repeat` is
therefore a per-material world-scale setting (tiles per metre), identical for
every surface that uses it — never per-item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The textures axis had no UI control. Add Colored (textures on — show item
materials, textures, vertex colours) / Monochrome (textures off — flat clay
by surface role) options to the Render dropdown, wired to useViewer.textures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In colored mode (textures on), every item now renders its authored material
— textures, vertex colours, and default colours — instead of being stripped
to the uniform off-white baseMaterial. AI-generated and vertex-coloured items
show their real appearance; slot resolution (override -> curated -> authored)
is unchanged. Monochrome (textures off) still collapses to the furnishing
clay role colour (the escape hatch). Drops the now-unused isAuthored marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#407 ("Always-visible placement ghosts + true-nearest 2D opening snap")
restructured the door/window placement tools: it split the old
create-in-resolve into a pure resolveWallPlacement() + side-effecting
applyWallTarget(), added an off-host floating ghost (fallbackPose / showGhostAt),
unified wall hover into onWallHover, and extracted commit{Door,Window}AtWall.
Conflict resolution (door/tool.tsx, window/tool.tsx):
- Re-homed the single publishOpeningGuidesForWallEvent() call into applyWallTarget
(after the draft update + updateCursor), using that scope (wall,
getSlabElevationForWall(wall)); door includeVertical:false, window true.
- Routed clearOpeningGuides3D() through showGhostAt so every off-host fallback
path clears; kept clears in hideCursor, commit helpers, onRoofHover, teardown.
- Made the window sill snap (resolvePlacementY) event-free and call it from the
pure resolveWallPlacement, so hover + click both get sill/centre/top snapping;
Shift bypasses, the moving draft is excluded via ignoreId.
- Dropped the branch's inline onWallClick in favour of #407's onWallClick +
commitWindowAtWall (no behavior lost).
- Reconstructed both files' import blocks, which the auto-merge had truncated to
stubs (only tsc caught it).
All other conflicts auto-merged (registry types, floorplan-registry-layer,
both move-tools). Verified: typecheck 9/9, biome clean, nodes 169 + core 594
tests pass, editor `bun run build` 7/7. Merge resolution reviewed by Codex
(adversarial): no semantic regressions; all #407 behavior preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reuse one THREE.Line + preallocated position buffer per guide slot, mutating
endpoints in place each drag tick instead of rebuilding the geometry, line, and
two Vector3s and re-uploading the GPU buffer every frame. Key guides by a stable
semantic id (sill / head / gap:side / vertical / spacing:i) so a slot that
persists keeps its React element and drei <Html> pill mounted as the guide set
churns, rather than remounting under shifting index keys.
Also: make useOpeningGuides.clear() a no-op when already empty so the common
no-guide hover frame doesn't push a fresh [] and re-render to the same nothing;
dispose the move-tool cursor EdgesGeometry on unmount; and memoize the
placement-tool cursor EdgesGeometry (static fallback dims) so it isn't
reallocated and orphaned on every render during placement.
Reviewed by Codex (peer + adversarial): no correctness, hook-order, or GPU-leak
regressions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(editor): always-visible translucent placement ghost for openings + roof accessories
When a host-surface placement tool is armed, the node's real geometry now
follows the cursor everywhere as a translucent ghost: tinted invalid (red)
and unconfirmable off-host, snapping onto its host surface (wall/roof) with
the existing valid/invalid affordances when near. This replaces the old red
wireframe box (door/window) and red DragBoundingBox (roof accessories), so
the armed tool is visible before the cursor reaches a placeable surface.
- New shared `applyGhost` helper (nodes/src/shared/ghost-materials.ts):
clones materials, disables raycast (avoids cursor-ray starvation),
tints invalid; cleanup disposes only the clones.
- New door/window preview components built from the real geometry via new
`buildDoorPreviewMesh`/`buildWindowPreviewMesh` viewer exports; tools float
the ghost via a `fallbackPose` that is mutually exclusive with the on-host
draft + wireframe outline.
- `RoofAttachmentFallbackPreview` gains a `ghost` prop; all 11 roof-accessory
tools pass their real preview (invalid-tinted) instead of a box `size`.
Snapping behavior is unchanged (no proximity snap yet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): magnetic proximity wall-snap for door/window placement
The door/window ghost now follows the cursor over the floor like a moving
item and magnetically snaps onto the nearest wall within range (1.5 m), then
releases back to free-follow when the cursor moves away — instead of only
attaching on a direct wall-mesh ray hit. A grid-snap sound plays each time it
snaps onto a new spot, so it reads as moving a physical object that can only
land on walls.
- Plan-space proximity via the existing `findClosestWallInPlan` (the same
helper the 2D floor-plan move uses): level-scoped, skips curved walls,
returns wall + along-wall localX + side + wall-local rotation.
- `grid:move` drives the snap and `grid:click` commits when proximity-snapped;
a direct wall-mesh hover (wall:enter/move) still owns the precise face side.
Both paths share `applyWallTarget` (create the draft once, reparent only on
an actual wall change) and a shared commit that refreshes alignment anchors.
- Disambiguation without a stuck flag: a per-pointermove `timeStamp` gate
(R3F + the grid raycast share the source DOM event) plus a `cameraDragging`
guard and stale-`hostKind` reset, so a missed wall:leave during a camera
orbit can't strand the draft.
- Window keeps its sill height on the floor path (the floor cursor carries no
wall-face Y) — defaults to a ~0.9 m sill, mirroring the 2D move.
- Shift bypasses the along-wall grid/alignment snap but still attaches to the
nearest wall, matching the 3D-hover and 2D-move conventions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): door/window preset placement follows the cursor over open floor
The community preset/catalog flow places doors and windows through the
isNew move path (MoveDoorTool / MoveWindowTool), which had no free-follow:
the fresh clone was parented to the level at the origin and only became
visible once the cursor reached a wall, so over empty floor nothing tracked
the cursor. Now the move tools mirror the def.tool placement behaviour:
- Off-wall, the real node rides the cursor like an item (reparented to the
level, positioned at the building-local cursor) so it's obvious what's
being placed before it attaches.
- Within range of a wall it magnetically snaps on via findClosestWallInPlan
(the same plan-space helper the 2D move uses), releasing back to free-follow
when the cursor moves away, and plays the grid-snap sound on each new snap.
- grid:click commits only when snapped (open floor is a no-op — a door/window
needs a wall); the wall/roof mesh-hover paths are unchanged and still own
their own click. A per-pointermove timeStamp gate + cameraDragging guard
keep the floor handler from fighting a wall/roof hover.
- Windows default to a ~0.9m sill while off-wall (fresh preset clones carry
position [0,0,0], which buried half the window below the floor).
The wall/roof commit body is extracted into a shared commitToWall so the
mesh-click and proximity-click paths stay identical. Existing-node moves are
fully restored on cancel/unmount (the node stays isTransient through
free-follow). Standalone-editor def.tool placement already had this in a
prior commit; this brings the community move path to parity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): 2D floorplan ghost follows the cursor for door/window placement
Mirrors the 3D free-follow in the top-down floor plan: while placing a door
or window, a loose footprint rectangle now follows the cursor over open floor
so it's obvious what's being placed before it snaps to a wall. The instant the
cursor nears a wall, the existing synthesized wall:enter/move path takes over
and the real on-wall door/window symbol (swing arc, etc.) replaces the ghost.
- The opening-placement pointer-move handler in floorplan-panel sets a new
`openingGhostPoint` on the off-wall (findClosestWallPoint miss) branch and
clears it on a wall hit; a loose width × 0.1m rectangle renders at that point
inside the floor-plan scene group (same world→SVG transform as every glyph).
- Width comes from the moving node or the kind default (door 0.9 / window 1.5).
- The ghost clears when opening placement ends (tool/mode change, cancel,
commit) and on level change, so no stale rectangle lingers.
Deliberately a plain rectangle, not the full swing-arc symbol: off-wall there's
no host to orient the swing to. The shared door/window def.floorplan builders
are untouched — overloading them with a wall-less fallback would make
roof-hosted doors (parent is a roof segment, builder returns null today) draw
stray rectangles in plan. Keeping the preview in the editor layer avoids that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): faithful 2D door/window ghost, 2D wall-slide, R-flip during placement; 3D snap only on hover
Three placement fixes plus a snapping revision, for both the standalone
def.tool path and the community isNew move path (door + window):
- 2D faithful ghost: the off-wall placement ghost now renders the real
blueprint symbol (door swing arc / window panes) following the cursor, not a
bare rectangle. Done by publishing a transient opening on a synthetic wall to
usePlacementPreview (extended with a `parentNode` fed as the builder's
ctx.parent) so the real def.floorplan builder draws it. Cleared on wall-hit,
on commit, on placement-inactive, and on level change.
- 2D slide-along-wall: the floor-plan registry layer ignored useLiveTransforms
for door/window (only floor-placed + slab/ceiling/zone), so a same-wall slide
updated the 3D mesh but left the 2D symbol frozen. It now merges the
wall-local live position/rotation onto the node (keeping parentId) so the 2D
symbol slides with the cursor.
- R-flip during placement: pressing R now flips a door/window's facing
(front ↔ back, rotation += π) before commit — the placement tools own R while
placing (the global selection-based R/T handler stands down via
isPlacingOpening so it can't double-fire). No-op on roof faces (front-only).
- 3D snapping zero-padding: removed the 1.5 m proximity magnet; in 3D the
opening free-follows the cursor over open floor and snaps only when the cursor
ray actually hovers a wall/roof mesh (big raycast targets). 2D keeps its
0.5 m findClosestWallPoint padding since plan walls are thin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): make FloorplanRegistryMoveOverlay the sole 2D owner of door/window placement
Community door/window placement (movingNode + metadata.isNew) had TWO 2D paths
running at once: the floorplan-panel synthesized wall:*/grid:* events (driving
the 3D MoveDoorTool) AND FloorplanRegistryMoveOverlay via def.floorplanMoveTarget.
They fought — R didn't flip the 2D symbol and clicks didn't commit in 2D, while
3D worked. The overlay + floorplanMoveTarget is the purpose-built 2D owner
(faithful def.floorplan symbol, plan-space CTM coords, Figma snap, single-undo
commit), so it now owns 2D opening placement when movingNode is set:
- floorplan-panel: the opening pointer-move branch + the registry grid catch-all
+ the background-click catch-all all now exclude the door/window MOVE case
(`!isOpeningMoveActive`), so the synthesized events no longer fire for it (they
still drive pure raw-build placement, which has no movingNode). Without the
catch-all exclusions the move case fell through to grid:move/grid:click, which
re-drove the 3D tool's free-follow and consumed the commit click.
- R-flip in 2D: `FloorplanMoveTargetSession` gains optional `flipSide()`;
door/window floorplan-move implement it (XOR the wall-derived side + π rotation,
re-running the last apply). The overlay's keydown calls `session.flipSide()` on
R — gated on `hasMovedSinceStart` so it only fires when the 2D pane is the
active mover (the 3D MoveDoorTool owns R in 3D/split; this prevents a double
flip / double cue on one R press).
- Commit in 2D now flows solely through the overlay's pointerup (no competing
synthesized wall:click), so click-to-place commits.
The global use-keyboard R/T already stands down during opening placement
(isPlacingOpening), so a selected node can't also flip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): 2D door/window move free-follows the cursor off-wall and commits only on a wall
Moving an existing door/window in the 2D floor plan: the move target's `apply`
early-returned off-wall (`if (!hit) return`), so the opening stayed frozen on
its old wall instead of following the cursor between walls (3D free-follows),
and an off-wall confirm click committed the stale last-wall position — looking
like the placement failed.
Now `doorFloorplanMoveTarget`/`windowFloorplanMoveTarget` mirror the 3D move:
- Off-wall, `apply` free-follows the cursor — hides the real node and floats the
faithful door/window symbol at the cursor via a synthetic wall published to
`usePlacementPreview` (the same preview layer fresh placement uses). The real
node is `visible:false` so the registry layer skips it (no double symbol).
- Back on a wall, it clears the ghost, reveals the real node, and snaps as before.
- `canCommit` returns false while off-wall, so an open-floor click reverts to the
pre-move snapshot (door returns to its wall) instead of committing in mid-air —
matching the 3D move, where clicking open floor commits nothing. On a wall the
commit lands normally.
The overlay's snapshot revert (cancel / invalid commit) and the on-wall `apply`'s
`visible:true` restore both guarantee the node is never left hidden after a move.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): 2D opening snap picks the true nearest wall, with a tighter radius
The 2D door/window snap felt too aggressive and could grab a wall further away
than the one the cursor was actually nearest. Root cause in
`findClosestWallInPlan`: it compared a candidate's true segment distance against
the previous best's `perpDistance` (signed offset to the wall's infinite line,
not the clamped segment distance). Near a wall end those diverge, so a closer
wall could be rejected / a farther one kept.
- Track the best segment distance and keep the strict minimum — the wall chosen
is now always the single closest segment to the cursor (true nearest), which
resolves correctly when many walls sit close together.
- Tighten the snap radius from 1.5 m to 0.4 m: plan walls are thin, so the old
radius snapped from far away. The opening now free-follows the cursor until
it's genuinely near a wall.
Only the 2D move/placement targets use this helper (3D snaps on raycast hover);
wall-attached items share the same improved nearest-wall behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): true-nearest 2D opening snap with dev hit-area overlay
Round 7 of the placement-ghosts work. Make the 2D door/window wall snap
always pick the wall nearest the cursor, fix fresh-placement snapping/slide
in 2D, and add a dev-only overlay that visualises each wall's snap region.
- Extract the plan-space nearest-wall-segment math to core
(`lib/wall-distance.ts`: collectLevelWallSegments / closestOnSegment /
nearestWallSegment / WALL_SNAP_DISTANCE_M). `findClosestWallInPlan`
delegates to it, so the snap and the debug overlay share one source of
truth. WallHit contract unchanged.
- door/window 2D move now resolves the host level via the shared
`getOpeningHostLevelId` (wall-hosted, roof-hosted, AND fresh-placement
parented straight to the level — the last case previously resolved to the
building, so a new opening never snapped in 2D).
- Cursor resolver switched to absolute mode: query the snap with the true
cursor, not the original-wall position + grab delta, so it picks the
cursor-nearest wall (matching the 3D move) instead of a far wall across a
thin gap.
- 2D move clears any stale `useLiveTransforms` entry for the node each apply:
the registry layer renders door/window from the live transform in
preference to the scene node, so a leftover entry from the 3D tool froze
the 2D slide for fresh / re-armed openings.
- Fresh window defaults to a 0.9 m sill in 2D (was sitting half-below floor
at y=0), matching the 3D MoveWindowTool.
- New dev-only FloorplanVoronoiLayer + `show2dVoronoi` editor flag: draws
each wall's snap hit area as an analytic capsule (no grid sampling), gated
on a developer-menu toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Insert the plan's middle resolution step for item slots: a curated
default baked into the GLB as a per-material 'pascal_material' extra
(three's GLTFLoader copies material extras to userData). Resolution per
slot is now: node.slots[slotId] override -> pascal_material curated
default -> authored GLB material. Bare ids resolve as library:<id>;
unresolved refs fall through. Runtime reads only the GLB (no DB).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three related editor fixes surfaced while debugging a captured house
project that rendered an infinite wall and failed to load.
Infinite wall (core/systems/wall/wall-mitering.ts):
Junction miters are line-line intersections, so the joint point sits
~halfThickness/sin(theta) from the junction. The only guard was an
exact-parallel check (det < 1e-9), so two walls meeting at a shallow
angle (a room-preset preview dragged onto an existing wall, or a wall
drawn nearly collinear to its neighbour) produced a joint point far
away — an infinite spike. Add a miter limit: reject joints farther
than 10x half-thickness from the junction and fall back to a square
joint, exactly like the parallel case.
Scene load failure (core/utils/heal-scene-graph.ts + validate-build-json
+ use-scene migrateNodes):
Capture wall-merge could leave a `children: [null]` entry (see the
matching merge-walls.ts fix in private-editor) and zero-length walls.
`null` children fail wall schema validation, so the whole scene fails
to load. Add a shared heal step — strip non-string child refs, drop
childless zero-length walls — run on every load path: import
validation now repairs instead of hard-failing (with a warning), and
setScene heals on the prod project-load path too.
Grid snap (nodes slab/ceiling/spawn tools):
These tools hardcoded a 0.5 m snap (Math.round(x*2)/2) and ignored the
editor's grid-snap setting, so the cursor jumped by 0.5 while later
vertices already followed the configured step. Route them through
snapPointToGrid / snapScalar with gridSnapStep.
Adds unit tests for the miter limit and the heal step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The editor autosave/load is the single source of truth for what travels
with a scene. Include document-level state (collections, materials) so it
survives reload in every embedder (community cloud save + standalone
localStorage):
- SceneGraph carries optional collections + materials.
- useAutoSave triggers on collections/materials reference changes (not just
nodes) and writes them into the saved graph + the unload flush.
- applySceneGraphToEditor restores them via setScene's extras arg on load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the opening proximity guides to two more interactions:
- PLACEMENT: the door/window placement tools publish the same 3D guides
(sill/head, edge proximity, sill alignment, equal-spacing) while a NEW
opening is being dropped; window placement also snaps its sill to a
neighbour's sill/centre/top (Shift bypass) — so "two windows aligned" reads
during placement, not just move.
- RESIZE: a new `onDrag` hook on the linear-resize handle descriptor lets the
door/window width/height arrows publish live guides for the edge being
resized — proximity to neighbours as the width grows, and the live sill/head
as a window's height changes. The generic LinearArrow stays kind-agnostic;
only door/window declare the hook.
Refactor (Codex review follow-up): one `publishOpeningGuidesForWallEvent`
wrapper now backs all four wall-event publish sites (door/window move +
placement) over a shared `makeWallToWorld`; window placement's repeated
sill-snap is a single `resolvePlacementY` helper. Opening guides clear on
commit / leave / cancel / roof-hover / unmount (mirroring the alignment-guide
lifecycle) and on resize end.
Codex-reviewed — no blockers; lifecycle/leaks, coordinate frame, sill-snap
precedence, and resize disposal confirmed. Typecheck + biome + 23 core + 170
nodes tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3 paint UI.
- Extract reusable MaterialPropertiesEditor (shared by the custom active-
paint editor and the scene-material editor).
- Curated Colors swatch row in the picker; catalog presets fork-on-tweak
by seeding a custom material from the entry's previewColor.
- Scene materials section in MaterialPaintPanel (shown once any exist):
list with swatch, inline rename, 'used by N parts', paint-with,
edit (live-propagates to every referencing part via the renderer's
sceneMaterials dep), duplicate, delete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the opening-guides service into the 3D door/window move tools and render the
wall-plane guides as the spatial twin of the 2D plan guides:
- sill / head height (floor → bottom edge, top edge → wall top) — windows only
- edge-to-edge proximity dimensions to the nearest neighbour each side
- a sill-alignment line + SNAP when a window shares a neighbour's sill / centre
/ top (competes with the 0.5m grid, Shift bypasses) — the chosen
"snap + guide" behaviour
- Figma-style equal-spacing "=" badges across a run of openings
Adds `useOpeningGuides` (editor store) + `OpeningGuides3DLayer` (raw THREE.Line
overlays + Html pills, mounted beside Alignment3DGuideLayer) and a thin
`opening-guides-runtime` helper (collect siblings / sill snap / publish / clear)
called from the door + window move-tools at their per-tick `applyPreview` hook;
guides clear on commit / cancel / leave / roof-hover / unmount.
Guides render in the move cursor's building-local frame (reuses `wallLocalToWorld`)
so they track the dragged opening exactly. Codex-reviewed (roof-hover stale-guide
clear, collapsed-dimension suppression). Placement-time guides reuse the same
helper and are the next step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route the door/window floor-plan placement dimensions through the new
opening-guides service:
- edge-to-edge clearance to the nearest neighbour (or wall end) on each
side, now with overlap suppression (previously nearest-only, ad-hoc).
- Figma-style equal-spacing — a "=" badge per gap on the wall centreline
whenever the moving opening is part of a run of 3+ (near-)equally-spaced
openings.
Adds the `equal-spacing-badge` FloorplanGeometry primitive, its 2D renderer
(distinct pink accent), and overlay registration. Shown while placing/moving.
Sill height + vertical alignment are 3D-only (a top-down plan has no vertical
axis) and land in the next phase.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure, Three.js-free wall-local geometry that will power directly-visible
proximity guides for doors and windows (rendered in the follow-up phases):
- sill / head height (floor → bottom edge, top edge → wall top)
- edge-to-edge proximity clearance to the nearest neighbour on each side
(or the wall end), with overlap suppression
- along-wall alignment (edge/centre coincidence with a neighbour)
- vertical alignment (shared sill / centre / top — "same sill height")
- Figma-style equal-spacing run detection across a series of 3+ openings
Single `computeOpeningGuides` entry plus exported detectors; 23 unit tests.
Codex-reviewed — equal-spacing uses a longest-equal-window scan (a greedy
first-gap anchor dropped valid runs), edge gaps suppress straddling overlaps,
and the alignment detectors guard against the moving opening appearing in its
own sibling list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Doors and windows could only be moved via the floating action menu — their
3D handle rig declared width/height resize arrows but no move grip, and
Ctrl/Meta-drag was a no-op for them. Add a press-drag move cross and make
direct-drag work for every bespoke-mover kind.
- door/window: add a `tap-action` `move-cross` handle (plane node-normal,
portal grandparent, `engageMoveDrag`) mirroring the item wall grip. It
routes through the existing per-kind move tool (3D `affordanceTools.move`,
2D `floorplanMoveTarget`) — wall-bound slide + re-host onto another wall —
so the grip, the floating Move button, and the 2D plan's move dot share one
pipeline. Grab-drag-release commits without a second click.
- canDirectMoveNode: gate Ctrl/Meta-drag on `movable || affordanceTools.move`
(the 3D-mountable move paths) instead of `movable` only, so doors/windows/
walls/slabs/stairs/… are draggable in 3D as they already are in 2D.
Floorplan-only movers (zone) stay excluded — no 3D tool mounts. The
floating helper auto-syncs (it reads canDirectMoveNode).
- TapActionArrow: honor `plane: 'node-normal'` by tilting the move cross
[π/2,0,0] into the wall face — previously ignored, so the item wall grip
rendered flat too. Now door/window/wall-item crosses lie in the wall.
- use-node-events: split the drag-suppression gate. `inputDragging` still
suppresses SELECTION events (the synthesized release-click would re-select),
but no longer suppresses SPATIAL events (enter/move/leave) — a
surface-following move tool runs with `inputDragging` set and needs
wall:move to track the cursor. General consumers that must ignore drags
(viewer hover, box-select) already self-gate on `inputDragging`; the
editor's select-hover and paint-preview enter handlers now gate on it too.
- handle-arrow: make handle hit areas inert while `placementDragMode` is set,
so a move grip riding the dragged node can't intercept the ray and starve
the move tool's surface raycast.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: sync bun.lock with 0.9.1 workspace versions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(viewer): stop interactive overlays from starving frames and flickering the UI
Every interactive item mounted a drei <Html occlude> overlay
unconditionally — invisible (opacity 0) when no zone was selected, but
still alive. With `occlude` as a bare boolean, drei raycasts the entire
scene per overlay on every camera-move frame, and rewrites each
element's z-index while toggling display when the occlusion flips. On
scenes with hundreds of interactive items (recessed lights, ceiling
fans) this starved the frame budget and made the whole DOM UI blink
during camera moves while the WebGPU canvas stayed healthy.
Overlays now mount only while a zone is selected and the item sits
inside its polygon, fade in/out over 300ms (the child components stay
rendered so the exit transition can play before the <Html> unmounts),
and drop `occlude` entirely. eps=-1 works around a drei mount bug: its
mount path writes the element transform without the distanceFactor
scale, and with a static camera the eps guard never re-applies it, so
freshly mounted overlays stayed mis-scaled until the camera moved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(viewer): break down dirty nodes by kind in the perf overlay
DIRTY now reads e.g. "29 (12 wall, 9 ceiling, 8 item)" — sorted by
count, only non-zero kinds, with a "missing" bucket for dirty ids whose
node no longer exists. Makes dirty-mark leaks attributable at a glance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(core): skip dirty marks for kinds with no dirty consumer
dirtyNodes is consumed by GeometrySystem (def.geometry),
FloorElevationSystem (capabilities.floorPlaced), and the legacy
per-kind viewer systems. Site, building, level, zone, and guide match
none of those, so their marks were never cleared: they accumulated for
the whole session (every child create/delete dirties its parent),
permanently defeated every consumer's empty-set early exit each frame,
and polluted the perf overlay's DIRTY readout.
NodeDefinition gains an explicit dirtyTracking?: boolean opt-out
(default tracked — no derivable predicate exists since wall's dirty
consumption lives in the viewer while zone/guide/level declare
def.system for unrelated per-frame work). markDirty consults the
registry; the five structural kinds opt out.
Also fixes a second leak: deleteNodesAction never removed deleted ids
from the dirty set, and every consumer skips missing nodes without
clearing them, so marks on deleted nodes lived forever.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style: replace concise-arrow forEach with for...of in deleteNodesAction
biome's useIterableCallbackReturn rejects forEach callbacks that
implicitly return a value (Set.add / clearDirty).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Dual review pass (Claude multi-angle + Codex release-quality). One
correctness fix and the agreed do-now cleanups:
- fix: clone-scene-graph remaps roofSegmentId like wallId in both
clone paths — duplicated scenes/levels kept pointing roof-hosted
children at the original segments.
- extract the settled, stateless roof target/cursor math shared by the
four door/window tools into shared/roof-wall-opening-placement.ts
(resolveRoofWallOpeningTarget + getRoofWallOpeningCursorPose +
worldToSelectedBuildingLocal); tools keep the stateful lifecycle
(drafts, undo/temporal, commit field lists). −199 net lines.
- rename host-generic state: currentWallId→currentHostId,
markWallDirty→markHostDirty (they hold segment ids too); capability
cascadesViaHostSegment→dirtyHandledByOwnSystem (behavior-facing,
before the public API hardens).
- drop getRoofAccessoryKinds from core's public API — its only caller
was the standalone Build tab, which now enumerates the registry
inline with its app-specific filter.
- window move-tool uses the shared stripPlacementMetadataFlags; stale
"segment-local" comment fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Floating action menu re-derives its anchor when the selected node's
geometry rebuilds (position/index attribute versions as the key) and
accounts for roof height via getActiveRoofHeight + effective nodes.
Wall-mounted items join doors/windows on roof-segment wall faces, and
the storage model moves to FACE-LOCAL coordinates so hosted children
track segment edits live.
- Children store roofFace + position [u, v, z-from-mid-plane] with
rotation 0 in-frame — the exact wall-child conventions (the wall
volume's mid-plane lands on the nominal footprint). A shared
<RoofFaceHostFrame> derives segment pose + face frame from the
live-override-merged segment: children follow resize handle drags in
real time and never jump on commit. No re-anchor cascade needed —
position is authoritative, the frame is derived. migrateNodes
converts branch-era segment-local data.
- Items: roofWallStrategy + roof:* handlers in the placement
coordinator (surface 'roof-wall'), Shift free-place normalized with
walls, ItemSystem wall-side push extended to segment hosts, correct
2D plan glyphs via face→segment→roof pose composition. The roof
hit resolver + overlap guard moved to @pascal-app/editor (the
coordinator lives there; nodes already depends on editor).
- Cuts: subtractAccessoryCuts extracted and applied in BOTH the
merged-shell and per-segment CSG paths (full edit mode / painted
segments used to lose every hole), built from the CURRENT host
geometry and live-effective children so holes follow segment and
opening drags.
- Handle rig: the grandparent portal now maps the node's world pose
into the portal frame instead of composing parent+node registry
poses — correct for any nesting (the face-frame group broke the
old assumption), identical for walls.
- Host-field hygiene: useDraftNode.commit/adopt and the window panel
duplicate forward roofSegmentId/roofFace/wallId; every roof↔wall
re-anchor clears and every revert restores them.
Codex-reviewed (design consultation, adversarial rounds on the
replaced cascade and on this refactor); frame conventions locked by
unit tests. Record: private-editor plans/editor-roof-wall-openings.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: zone grid snapping, shelf position panel, deselect on item placement
- Zone tool reads the editor's gridSnapStep (0.5/0.25/0.1/0.05) instead
of a hardcoded 0.5 for both cursor move and click snapping.
- Shelf inspector gains a Position group (vec3 X/Y/Z sliders), matching
the item panel.
- Item catalog clears the viewer selection before arming placement so
shortcuts (rotate & co) don't hit both the ghost and the selected node.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor: move level display naming into @pascal-app/core
getDefaultLevelName / getLevelDisplayName ("Ground Floor" / "Floor N" /
"Basement N") lived in packages/editor's internal lib, so viewer-only
surfaces couldn't reach them and fell back to hand-rolled "Level N"
labels. The helpers are pure domain logic, so they move to core and
export from its barrel; the editor package's seven call sites now import
them from there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix: guard preset placement against double commit from duplicate click events
A single physical click reaches commitAtCursor twice: node clicks are
synthesized on pointerup (use-node-events) while grid:click rides the
native click event from a canvas DOM listener (use-grid-events) that
deliberately ignores stopPropagation. The second pass found the fresh
draft already deleted and took the orphan re-create path, minting a
hidden ghost copy, replaying the placement SFX, and abandoning the
re-armed clone as a second ghost.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: show the compass in all view modes via the viewer-area container
The floorplan compass now portals onto the always-visible viewer area
instead of living inside the (display:none in 3D) floorplan pane. The
2D/3D navigation poses already sync through navigationSyncPose, so the
needle and align-to-north stay correct in 2d, 3d, and split alike.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Openings now host on the walls a roof segment generates — the base
walls under the roof and the coplanar gable/shed/gambrel end faces, so
a window can sit in a gable pediment.
- core: roof-segment-walls.ts models the four vertical faces as 2D
frames (u along face, v height) with convex profile polygons that
mirror the wall volume getRoofSegmentBrushes builds; rect-in-profile
clamping and anchored resize limits via half-plane algebra.
- schemas: optional roofSegmentId on door/window; position is the
segment-local wall mid-plane center, rotation[1] the face yaw.
- cut: reuses capabilities.roofAccessory.buildCut; new cutScope: 'wall'
subtracts from the wall brush only. cascadesViaHostSegment keeps the
roof-merge loop from consuming door/window dirty marks (their own
systems cascade via parentId).
- tools: roof:* handlers in door/window tool + move-tool (the Build-tab
preset path), with roofSegmentId cleared/restored across every
roof<->wall re-anchor and revert; shared hit resolver normalizes
normals through world space (merged mesh vs painted segment frames).
- fix: RoofSystem no longer rebuilds per-segment CSG in accessory-reveal
mode — the uncut rebuild used to draw over the merged shell's fresh
opening until deselect.
- fix: the walkthrough collider world now prunes by renderer-effective
visibility; stale uncut segment CSG inside the hidden segments-wrapper
blocked the player at openings the merged shell had cut through.
Known gap: painted segments render per-segment CSG without accessory
cuts (pre-existing, also affects skylight/dormer). Twice Codex-reviewed;
details in private-editor plans/editor-roof-wall-openings.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The site ground collider was sized to the scene footprint with a 30 m
minimum — exactly the default site polygon, so one step past the site
boundary left nothing under the character controller and the player
fell into the void. The ground collider now extends 2 km (still a
single BVH box) so the ground plane acts unbounded, and a respawn net
in the first-person frame loop recovers the controller if it ever ends
up below every collider (e.g. scenes with no site node), preferring the
live spawn node over the mount-time start position.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- registry: getRoofAccessoryKinds() enumerates kinds declaring the
roofAccessory capability, in deterministic builtin-list order (mirrors
getSelectableKinds); exported from the registry barrel.
- apps/editor build-tab: a "Features" group under the Roof tile, discovered
from the registry (no DB) and activating each kind's roof-attach tool —
parity with the community editor's roof features.
- site-boundary-editor: hide the flag handle group around thumbnail captures
(they render on SCENE_LAYER so the thumbnail camera can't layer-filter them),
matching handle-arrow.tsx — keeps flags out of preset/snapshot thumbnails.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v0.9.0 GitHub release declared the trio at 0.9.0 but the npm publish
never ran (npm still serves 0.8.0). Aligning manifests so the release
workflow's bump=patch lands everything at the announced-next 0.9.1
(mcp 0.3.0 -> 0.3.1, ifc-converter 0.1.0 -> 0.1.1 keep their own lines,
matching the per-package version table style of previous releases).
Inter-package peer/dev refs move to ^0.9.0 so the workspace stays
self-linked during the release run; the workflow re-syncs them to the
final versions after bumping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Allow template forks to preserve scans
* Add room preset save trigger
* Show room preset action for selected zones
* Use standard inspector for selected zones
* Support room preset editor workflows
* Preview room clear-underneath items
- next 16.2.7 -> 16.2.6 in both apps. 16.2.7 regresses Turbopack dev route
matching for API routes nested below a dynamic segment (probed in the
private repo: /api/items/[id]/fork, /api/scenes/[id]/events 404 with the
router's HTML not-found; production builds unaffected; bisected
16.2.6 good / 16.2.7 bad). 16.2.6 carries all the 16.2.5/16.2.6
security fixes — only the bugfix backports are forgone.
- Remove @react-three/uikit-lucide from @pascal-app/editor dependencies:
zero imports in this repo or private-editor; dead weight in every npm
consumer's install.
- Pin @visual-json/react "latest" -> "^0.4.0" (current resolution;
"latest" in a published package's deps is unreproducible and was
inconsistent with private-editor's ^0.4.0).
- Add root override three: 0.184.0, mirroring private-editor's dedupe —
drops the nested stats-gl three@0.170.0 so the lockfile resolves a
single three (the stated single-instance invariant now holds here too).
Verified: build, check-types, biome check, 931 package tests green;
lockfile resolves one next (16.2.6) and one three (0.184.0);
oxide/lightningcss optionalDependency pins still match resolutions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Range edits:
- next 16.2.1 -> 16.2.7 in apps/editor + apps/ifc-converter. 16.2.5/16.2.6
fixed six high-severity advisories (middleware bypass, DoS, SSRF, XSS,
RSC cache poisoning); 16.2.7 is bugfix backports. No API/config changes.
- typescript 6.0.2 -> 6.0.3 everywhere, including aligning the 5.9.3
stragglers (@pascal-app/mcp, @repo/ui, @repo/eslint-config) that date
from the original scaffold and were never intentionally held back.
- @types/node in @pascal-app/mcp: ^25.5.0 -> ^22.19.20 — mcp was the only
workspace typed against non-LTS node 25; 22 matches the release
workflow's node and every other workspace.
- @number-flow/react ^0.5.14 -> ^0.6.0 (packages/editor, apps/editor);
the only breaking change (removed --number-flow-char-height CSS var)
is unused here.
- agentation ^2.3.2 -> ^3.0.2 (apps/editor devtool; v3 is a drop-in for
the props-less <Agentation /> usage, additions are opt-in).
- Root overrides: @types/react 19.2.14 -> 19.2.17,
@types/three 0.184.0 -> 0.184.1 (types-only fixes).
Lockfile refresh within existing ranges picks up react 19.2.7 (pairs with
next 16.2.7 — 19.2.6 had a server-action FormData regression), motion
12.40.0, three-mesh-bvh 0.9.10, @react-three/uikit-lucide 1.0.73, the
June radix wave, lucide-react 1.17.0, zustand 5.0.14, tailwind-merge
3.6.0, geist 1.7.2 (fixes Geist Mono ligature regression), react-grab
0.1.44, biome 2.4.16 + ultracite 7.8.2, turbo 2.9.17.
Held: three stays 0.184.0 (npm latest; single-instance constraint),
tailwindcss 4.3.0 + lightningcss 1.32.0 already match the pinned
optionalDependencies native binaries, eslint 10 major not taken
(@repo/ui is unconsumed legacy scaffold).
Verified: turbo build, check-types, biome check, and 931 package tests
green; bun.lock stays lockfileVersion 1 (CI bun 1.3.0 compatible);
single next/three resolution confirmed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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
* Snapshot: ridge vent locks to segment ridge during placement, free sliders after
Placement tool clamps the cursor to the segment's ridge line for all roof
types (gable, hip, shed, gambrel, dutch, mansard; flat rejected). Renderer
re-derives Y from the live surface plus a small lift, and treats stored
position[1] / position[2] as user offsets so the inspector sliders move
the vent after placement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(roof): contribute outer silhouette to alignment-guide candidates
Roof has no centred-box footprint (it's the union of its `roof-segment`
children) so the capability bridge needs the resolved AABB directly. We
build it from the children's corners in roof-local space, then transform
to world coords. Roofs only contribute as static candidates — the
move-roof tool drives them by origin, so the relocatable-box path never
applies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(floorplan): upright text geometry that stays horizontal under scene rotation
Floor-plan text labels were rotating with the 90° default scene rotation,
making zone names read sideways. Add an `upright` flag to text geometry:
when set, the registry layer counter-rotates the label by sceneRotationDeg
around its anchor so it reads horizontally on screen. Zone labels opt in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): redesign material-paint cursor badge and gate floor-plan paint mode
Paint cursor swaps the inline icon+label chip for a stacked badge — a
glowing accent stem pointing down to the hit point with the paint icon
above — so the cue reads as "pointer" rather than "tooltip". Switches
the accent to indigo. The 2D floor-plan now also shows the paint-icon
overlay and routes pointer interactions to the painter when in
material-paint mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): 90° rotate action on the floating building menu
Adds a Rotate button to the building action menu (and a generic `onRotate`
slot to NodeActionMenu). Each click rotates the building 90° CW around
its world-bbox center, mirroring the offset compensation
`MoveBuildingContent` uses during R/T-rotate drags — so the building
spins in place instead of orbiting its (often off-centre) origin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(column,shelf): show cursor sphere during placement preview
Matches the placement affordance other floor-placed tools already have
so users see the snap point as a discrete dot alongside the ghost mesh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(ifc-converter): update next-env types path to .next/dev/types
Next.js moved generated route types under `.next/dev/types/` — regenerate
the reference so the type-check resolves them again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ridge-vent): track parent segment's live overrides for real-time follow
While dragging a roof-segment resize handle (width / depth / pitch /
wallHeight / rotation), the new dimensions stream through
useLiveNodeOverrides and only flush to the store on release — so the
ridge vent was snapping to the new ridge only after the drag ended.
Merge segment overrides into the renderer's segment view so the vent
rides the resize in real time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): step building rotation in 15° increments
Cuts the floating building menu's rotate-step from 90° to 15° so each
click nudges the building rather than slamming it to the next cardinal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): world-frame alignment guides and grid snap under building rotation
Brings the 2D floor-plan alignment + grid behaviour in line with 3D so
guides and snap follow the world XZ grid regardless of how the active
building is rotated.
Core: adds resolveAlignmentInBuildingWorld + snapWorldXZToBuildingLocal,
plus a shared BuildingPose type, so callers can resolve in world space
while staying in building-local coords downstream.
Editor: introduces packages/editor/src/lib/world-grid-snap.ts as the
single entry point for tools that need world-frame alignment / grid
snap (resolveAlignmentForActiveBuilding, getActiveBuildingPose,
snapWorldXZForActiveBuilding, snapBuildingLocalToWorldGrid). Honours
useLiveTransforms and resolves the active building via level.parentId
first so the helper stays in sync with the floor-plan panel.
Alignment store unification: applyFloorplanAlignment and the registry
move overlay now publish guides in world XZ — the same frame the 3D
tools already use — so the shared useAlignmentGuides store carries one
consistent coordinate system. The 2D layer renders world XZ via the
fixed world → SVG transform (a 90° rotation around the building's
world position; independent of building rotation), so guides come out
parallel to the world-axis-aligned floor-plan grid in every case.
Drafting + move sessions across walls, fences, slabs, ceilings,
columns, items, shelves, roofs, stairs, zones and elevators are
re-routed through the new helpers so cursor placement, grid snap,
draft endpoints, and move commits all land on the world XZ grid
even when the building has been rotated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(editor): drop building drag-rotate and grid building-XZ chase
Two behaviours added recently were causing the world grid to slide
around during building edits. Reverts both, keeping the rest of the
world-frame alignment work intact.
- floating-building-action-menu / node-action-menu: roll the rotate
button back to a single click that increments rotation by 15°
around the bbox center (state at 3eb02fde). The drag-rotate from
b50c0d5a streamed live position updates per frame, which the
floor plan and grid both followed.
- grid: stop chasing the active building's world XZ each frame
(pre-c00a2469 behaviour). The grid stays anchored at world (0, 0)
so it doesn't visibly sweep when the building origin moves —
during a drag, an R/T rotation in the move tool, or a click
rotate that re-anchors the origin to the bbox-pivot. Y still
lerps to the active level's floor height.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): consistent building rotation pivot + 2D position sync
Several connected fixes to building rotate/move so the 3D mesh, the
2D floor plan, and alignment guides all stay visually consistent
across rotations and drags.
Rotation-invariant pivot
- New `getBuildingLocalBboxCenter` helper walks descendant mesh
geometry bounds in the building's LOCAL frame, returning a
centroid that doesn't depend on the building's current world
rotation. The previous approach derived the pivot from the world
AABB at the current rotation, which made the local pivot subtly
drift each time the building was rotated.
- Floating menu click-rotate and move-tool R/T rotation now seed
their pivot from this helper, so successive rotations stay
pinned at the same world point.
Move-tool stability
- R/T rotation always pivots around the current world bbox center,
even before the cursor has moved onto the grid (previously it
fell back to rotating around the origin in that case).
- Commit (`onGridClick`) now writes the mesh's actual on-screen
pose instead of recomputing it from the click event's grid
coords, removing a one-cell snap on release.
Alignment resolver
- Tie-break is now perp-first, primary-second. The previous order
was reliable when wall faces shared an exact axis-aligned coord
(pre-rotation), but post-rotation float deltas meant primary
never tied and the resolver could lock onto the far corner of a
candidate. Perp-first picks the visually closest point.
2D ↔ 3D sync
- The floor-plan SVG scene group now applies a live `translate(...)`
derived from `(committedBuildingPosition → live buildingPosition)`,
in addition to the existing live rotation. Building-local content
slides in lockstep with the 3D mesh during a drag.
- The alignment-guide layer projects from world XZ using the
COMMITTED building position, so guides stay locked to their world
coordinates while the building slides past them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Merge remote-tracking branch 'origin/main' into fix/fir-5-june
* feat(wall): alt-click commits a single wall instead of chaining
Holding Alt on the click that completes a segment now stops drafting
instead of starting a new segment from the just-placed endpoint. Same
behaviour wired in both the 3D wall tool and the 2D floor-plan
placement hook (which already passed `singleWall: event.altKey` —
the 2D handler was ignoring the flag).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(editor): drop 15° rotate from floating building action menu
Removes the rotate button on the floating building menu and the
`getBuildingLocalBboxCenter` pivot helper that only existed to support
it. The selected-group rotate handle remains the way to rotate a
building.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(ifc-converter): point next-env at .next/types instead of .next/dev/types
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): unify 3D move grip with floating-menu Move via tap-to-engage
The translate gizmo on the 3D bounding box now hands the node to the
registry move tool (same path as the floating action menu's Move
button) instead of running its own plane-drag. One move flow — green
bounding box, cursor follow, alignment guides, R/T rotation,
click-to-commit — drives both entry points.
Adds a `dragBounds` capability on the node registry so kinds whose
rendered mesh contains extras (per-level landing assemblies on an
elevator, etc.) can declare the box the user thinks of as "the thing
being dragged." Elevator and stair opt in; everything else still
auto-measures. `MoveRoofTool` now shows the green box for whole-stair
and whole-roof moves, and aligns roofs by their footprint corners
(previously only stairs did). Slab floor-plan moves clear
`autoFromWalls` on commit so space-detection doesn't snap the slab
back, via a new `extraCommitFields` hook on the polygon-centroid
move target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): 2D alignment guides in building-local frame + world-grid wall snap
The 2D floor-plan alignment layer now mounts inside the rotated scene
<g> and reads from an editor-local `useAlignmentGuides` store —
guides are stored in building-local meters and follow the SVG
transform that already carries the rest of the floor-plan geometry.
Pill labels are counter-rotated by the scene rotation so they stay
upright under building rotation. The 3D pipeline keeps its own
world-frame store; the two surfaces no longer share frames.
Wall drafting picks up an optional `gridSnap` override so the 2D
floor-plan can snap drafts onto the world-XZ grid even when the
active building is rotated — without it, a rotated building's
drafted wall would chase the local grid and miss the visible lines.
`snapPointTo45Degrees` and `snapWallDraftPointDetailed` route through
the override when supplied; otherwise the prior local-axis grid snap
is unchanged. The floor-plan panel wires `snapBuildingLocalToWorldGrid`
through the placement hook and exports `WALL_GRID_STEP` to support it.
Also fixes the minor/major grid stroke palette swap so minor lines
render with the minor palette and majors with the major palette.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(editor): drop dead onRotate slot, memoize dragBounds snapshot
Removes the unreachable `onRotate` prop, `RotateCw` import, and
rotate button from `NodeActionMenu` — its only caller was the
floating building menu's 15° rotate, which was removed in d65fd5fe.
Memoizes `MoveRegistryNodeTool`'s `dragBounds` lookup so it captures
the scene snapshot once per node instead of re-reading
`useScene.getState().nodes` on every render. Bounds depend only on
`node` (locked for the tool's lifetime) and start-time sibling state
(elevator shaft height from the level set), so a one-shot snapshot is
the right semantics — and it avoids implying a live subscription that
isn't there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: biome check --write (import order, formatting, unused import)
Auto-fixes from `bun check:fix`:
- import-order normalization in editor/nodes/core
- unused `snapToGrid` / `useLayoutEffect` imports dropped
- minor whitespace/wrapping tweaks
No behavioural change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>