Skip to content

editor: alignment guides + floor-plan move/placement parity#372

Merged
wass08 merged 27 commits into
pascalorg:mainfrom
sudhir9297:feat/wed-3-june
Jun 4, 2026
Merged

editor: alignment guides + floor-plan move/placement parity#372
wass08 merged 27 commits into
pascalorg:mainfrom
sudhir9297:feat/wed-3-june

Conversation

@sudhir9297
Copy link
Copy Markdown
Contributor

@sudhir9297 sudhir9297 commented Jun 4, 2026

What does this PR do?

Brings the 3D editor's Figma-style alignment experience to the 2D floor plan and unifies move/placement behaviour across node kinds, plus a batch of editor interaction polish that accumulated on this branch.

Alignment

  • Wall anchors now include ±thickness/2 face corners, so columns/items/etc. snap flush to wall faces (fixes the pillar↔wall case); shared by 2D and 3D.
  • A shared applyFloorplanAlignment / alignFloorplanDraftPoint helper drives alignment + guides for floor-plan moves, structural drafting (wall/fence/slab/zone/ceiling/roof), and wall/fence endpoint drags.
  • Door/window/wall-item moves get along-wall edge-to-edge snapping.
  • Level-scoped candidates: alignment now drops nodes that resolve to a different level, so a node directly below on another floor no longer snaps (alignment is XZ-only). Building-/site-scoped nodes with no level ancestor (e.g. an elevator shaft, which spans every floor) stay in the pool. The 2D floor plan deliberately keeps cross-floor alignment to the reference floor.
  • Elevator alignment footprint now derives from the outer shaft (shaft + wall, what's drawn), not the inset cab — the cab corners sat ~9 cm inside the visible edge, past the 8 cm snap, so the elevator never surfaced a guide.
  • Stairs now contribute plan-bbox anchors: shared stair-footprint geometry (rotateXZ, segment transforms, stairFootprintAABB) is extracted so slab-opening sync and alignment derive the chain identically (straight = segment chain, curved/spiral = annular sector).
  • The 3D alignment-guide ribbon is lifted to the active level's Y each frame, so it lies on the floor being edited when floors are stacked.

Floor-plan move/placement parity

  • Pivot-based moves matching 3D: polygon kinds (slab/ceiling/zone) move by centroid→cursor; stair/column/shelf by origin→cursor — no more grab-relative drift. Shelf/column write position directly so the 3D group no longer sticks on commit.
  • 2D placement ghosts (usePlacementPreview + FloorplanPlacementPreviewLayer) render a kind's def.floorplan footprint following the cursor — wired for column and elevator.
  • Reference-floor symbols: stairs/roofs/elevators/shelves/spawns on the reference floor now render through their registry def.floorplan builders, so the plan symbol is pixel-identical to one on the active floor.
  • Slab creation moved to the registry-driven slab tool (parity with ceiling): the 2D handlers only maintain draft state and forward the closing double-click; the 3D tool commits the node (so a slab is no longer built twice).
  • Fix: placing an elevator no longer deselects the active floor plan.

Editor interaction polish (earlier commits on the branch)

  • 3D alignment guides + drag bounding box across move/placement tools; guides snap to nearest real anchor.
  • Group move handle + shared group-transform core; axis-stable resize-arrow drag plane + slimmer gizmo handles.
  • Paint eraser + reset-all; box-select for building-scoped nodes (elevators); autosave kept alive across page unload.
  • Viewer: double-sided slab-hole side-walls; frameless wall-opening highlight fixes.
  • Recreate door/window draft on wall:move when null after placement.

Architecture review run on the floor-plan work (/review-architecture): 0 blockers; the two follow-ups (move the placement-preview store to editor, refresh the per-kind useLiveTransforms wiki note) are included in the final commit.

How to test

  1. bun dev, open a scene, switch to the 2D floor plan.
  2. With a few walls + a slab present, select Column and place one against a wall mid-span — its edge should snap to the wall face with a guide line + distance pill; hold Alt to bypass. A faint column footprint ghost should follow the cursor.
  3. Move a slab (and a ceiling/zone): it should track by its centroid (not the grab point) and align to neighbours' edges. Move a shelf/column/stair: tracks by origin, snaps to edges/wall faces.
  4. Draw a wall whose endpoint lines up with existing geometry → it snaps with a guide; drag a wall/fence corner → the corner snaps to neighbours' edges/wall faces.
  5. Place two doors on a wall → the second snaps edge-to-edge to the first / wall ends.
  6. Place an elevator in the floor plan → the active level/floor plan stays selected, a footprint ghost follows the cursor, and the elevator surfaces an alignment guide against nearby geometry.
  7. Stack two floors, place a stair/elevator on floor 1, then move a node on floor 0 directly above/below it → it should not snap across floors (3D), while the 2D reference floor still aligns to the floor below.
  8. Place/move a stair → it shows alignment guides off its plan footprint (straight, curved, and spiral).
  9. Draw a slab in the floor plan → exactly one slab is created (no duplicate).
  10. 3D regression: confirm column/shelf/item placement + move still snap and show guides (alignment anchors are shared).
  11. bun check and bun check-types should pass.

Screenshots / screen recording

N/A in this description — change is highly visual/interactive; a screen recording of the floor-plan alignment + ghost + pivot-move flows will be added on the PR. Reviewers can reproduce via the steps above.

Checklist

  • I've tested this locally with bun dev
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (if applicable)
  • This PR targets the main branch

sudhir9297 and others added 27 commits May 19, 2026 02:59
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>
Bring Figma-style alignment guides into the 3D editor, reusing the shared
pure resolver (`resolveAlignment`) and ephemeral guide store
(`useAlignmentGuides`) that previously only drove the 2D floor plan.

Core:
- `alignment-anchors.ts`: node→anchor adapters (footprint AABBs, corner
  anchors, wall/fence segment anchors) + `refineGuidesToGap` so a guide's
  line and distance read to the candidate's nearest edge, not the far side.
- `bboxCornerAnchors` + corner-only footprint anchors so alignment locks to
  item edges, never centrelines.
- `resolvePointSnap` (point-coincidence variant; kept for future use).

Editor:
- `Alignment3DGuideLayer`: dashed ribbon + flat floor dots + distance pill,
  in the project's indigo accent, mounted inside ToolManager's building-local
  group so guides render in the cursor's frame.
- Producers wired in the item move tool, item placement coordinator, and the
  wall + fence endpoint tools; walls and fences cross-align.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 3D alignment guide could place its end dot in empty space: a diagonal
wall (or any rotated / non-rectangular object) has bounding-box corners that
don't lie on the object, and `refineGuidesToGap` re-spanned the guide to
exactly those AABB edges — so the dot floated "along the coordinate" rather
than on the item.

- `resolveAlignment` now tie-breaks to the candidate anchor NEAREST on the
  perpendicular axis (after the tightest axis match). Anchors are real points
  (corners / endpoints / midpoints), so the guide always connects to the
  closest actual point — which also yields the facing-edge gap distance.
- All four producers (item move, item placement, wall + fence endpoints) now
  publish the raw resolver guides; the AABB nearest-edge re-span is gone.
- Removed the now-dead `refineGuidesToGap` and `resolvePointSnap` helpers
  (and their tests / exports).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ool polish

Add a group-move gizmo alongside the existing group-rotate handle, both
driven by a new shared `group-transform-shared` module (participant
classification, group-box + corner math, connected wall/fence component
expansion so attached structure transforms rigidly as one piece).

- core: refactor alignment-anchors collection + tests, extend handle registry
- editor: group-move-handle, group-transform-shared; rotate handle reuses them;
  node-arrow-handles gains click-swallow guard; box-select + placement tweaks
- nodes: move-tool updates across ceiling/column/slab/roof/registry; door math
  and panel adjustments; item definition cleanup
- nodes(fence): play `sfx:grid-snap` ticker on endpoint move, matching the
  wall endpoint tool (fixes missing audio feedback on fence side drag)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The autosave debounces writes by 1s and relies on a `beforeunload` flush
for anything still pending. That flush fired a plain `fetch` PUT, which
the browser cancels the instant the page unloads — so refreshing right
after an edit (e.g. painting a roof material) silently dropped the change
and the reload showed the last persisted scene.

Thread a `{ keepalive }` option through the save callback and set it on
the unload flush so the request survives the unload. Also listen for
`pagehide` (fires where `beforeunload` does not, e.g. mobile Safari /
bfcache) and clear the dirty flag up front so the two listeners don't
double-send. Normal debounced saves omit `keepalive` (its 64KB body cap
only constrains the best-effort unload flush, not regular saves).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Material paint gains an eraser (clear a surface back to its default) and a
"Reset all" action that defaults every painted surface on a node — for a
roof that includes each child segment — via a generic
`buildResetSurfaceMaterialUpdates` that nulls catch-all and role-specific
material fields without per-kind knowledge.

Also stop a single painted roof surface from bleeding onto the others:
`getEffectiveRoofSurfaceMaterial`, `getRoofMaterialArray`, and the segment
renderer no longer cross-fall-back between top/edge/wall. An unset role
resolves only to the legacy catch-all (back-compat) or the theme default,
so painting just the shingle, trim, or soffit stays on that surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the Figma-style 3D alignment guides to the placement and move
tools for columns, elevators, roofs, stairs, ceilings, slabs, fences,
walls, doors, and windows: each collects alignment anchors from the
scene, resolves a snap within the shared threshold, and drives the
`useAlignmentGuides` overlay. Wall openings (doors/windows) only snap
along their host wall via the new `wall-opening-alignment` helper.

Add a shared `DragBoundingBox` overlay (exported from the editor barrel)
that renders the dragged object's bounds during a move, wired into the
column move tool alongside the alignment snap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the linear resize-arrow's drag plane so it always contains the
handle's axis (view direction minus its along-axis component) instead of
a plane that merely faces the camera. The old camera-facing normal
collapsed when the axis pointed toward the viewer — screen motion barely
changed the axis component, so the resize crawled or stopped tracking the
cursor. Also slim the extruded arrow/handle geometry (shared by the node
arrows, wall side handles, and polygon editor) for a lighter gizmo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…penings

Draw the selected-wall opening highlight as a translucent block filling
the cutout volume (front-side culled) instead of a single vertical pane,
so it reads as an occupied slot from any angle — including a top-down
floorplan view where an edge-on pane was invisible. Also highlight a
directly-selected frameless opening (a `door` with openingKind
`'opening'`), which otherwise renders no geometry of its own, and reflect
live drag overrides via `useLiveNodeOverrides`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the slab in 3D rather than via ExtrudeGeometry so each hole-wall
quad is emitted twice with opposite winding. The slab material is forced
to FrontSide (DoubleSide poisons the MRT scene pass), under which
ExtrudeGeometry's single-sided hole walls get back-face culled and you
see straight through the cut. The doubled quads keep the cut's inner
thickness visible from any angle without z-fighting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Building-scoped selectable nodes (e.g. elevators) are children of the
building, not the active level, so the level walk never reached them.
Also walk the level's building children and box-test any registry-
selectable kind by its rendered bounds, matching the column/stair/shelf
path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/core/src/services/index.ts
#	packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
Shelf placement now snaps to Figma-style alignment guides by its
footprint edges (layered on grid snap, Alt bypasses), matching the
existing 3D move tool. Guides refresh after each drop and clear on
teardown.

Column placement migrates to the registry `def.tool` path so it can
render a translucent column ghost at the cursor (like the shelf build
tool) instead of a bare cursor sphere — the editor package can't import
the column geometry, so the tool now lives in packages/nodes:
- extract `ColumnBody` from the renderer and add a `ColumnPreview`
  (cloned translucent material, raycast disabled, origin-positioned)
- new `column/tool.tsx` registry placement tool with the same
  footprint-edge alignment as shelf / column move
- wire `def.tool` + tool hints; drop the now-unreachable legacy
  editor-side `ColumnTool` and its dead file

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the 3D editor's Figma-style alignment experience to the 2D floor
plan across every node kind, fix pivot semantics on move, and add 2D
placement ghosts.

Alignment
- Wall anchors now include ±thickness/2 face corners so columns/items/etc.
  snap flush to wall faces (fixes pillar↔wall); shared by 2D and 3D.
- Shared apply-alignment helper (applyFloorplanAlignment /
  alignFloorplanDraftPoint, with excludeIds) used by move sessions,
  structural drafting (wall/fence/slab/zone/ceiling/roof), and wall/fence
  endpoint drags.
- Door/window/wall-item moves get along-wall edge-to-edge snapping.
- Generic free-translate move path aligns by edges (corner anchors).

Pivot moves (2D)
- Polygon kinds (slab/ceiling/zone) move by centroid→cursor via a shared
  polygon-centroid mover; stair moves by origin→cursor; matching 3D.
- Shelf/column move targets write position directly (single source of
  truth) so the 3D group no longer sticks on commit.

Placement ghosts (2D)
- usePlacementPreview store + FloorplanPlacementPreviewLayer render a
  kind's def.floorplan footprint following the cursor; wired for column
  and elevator.

Fixes
- Elevator placement no longer deselects the active floor plan
  (preserve levelId through setSelection's hierarchy guard).
- Guides clear on every commit/cancel/unmount path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Move usePlacementPreview store from core to editor: placement ghosts
  are an editor/tool concern the read-only viewer never needs. Rewire the
  column tool (via the @pascal-app/editor public surface) and the
  editor-internal elevator tool + preview layer (relative imports).
- FloorplanPlacementPreviewLayer: read scene lazily in ctx.resolve instead
  of bulk-reading the nodes map during render.
- wiki/architecture/tools.md: refresh the stale useLiveTransforms-per-kind
  note to reflect item/shelf/column (world-plan) + slab/ceiling/zone (delta).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After a successful click-to-place, the click handler deletes the
transient draft and relies on the wall-rebuild → R3F pointer-enter
cascade to create a fresh draft for the next placement. If that cascade
doesn't fire synchronously (e.g. async geometry rebuild) the next
wall:move receives a null draftRef and bails — requiring leave/re-enter
to place again.

Fix: in onWallMove, when draftRef.current is null but we're hovering a
valid wall, recreate the draft immediately (same WindowNode.parse +
createNode path as onWallEnter). This is idempotent: if wall:enter does
fire first, destroyDraft() in onWallEnter cleans up cleanly.

Preserves parity with door multi-place behaviour, matching pascalorg#367's intent.
Mirror of the window fix one commit back: after click-to-place the
DoorTool deletes its transient draft and relies on the wall-rebuild \u2192
R3F pointer-enter cascade to spawn a fresh draft for the next placement.
When that cascade doesn't fire synchronously, the next wall:move sees a
null draftRef and bails \u2014 forcing a leave/re-enter to place again.

Recreate the draft in onWallMove when null and over a valid wall on the
current level. Idempotent with onWallEnter (destroyDraft cleans up if
both fire).
@wass08 wass08 merged commit 46f94b9 into pascalorg:main Jun 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants