ai-chatbot/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx
Sudhir Yadav bf25af6add
viewer: Fix Dutch roof trim artifacts (#452)
* 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

* Fix spiral stair openings and fence handle arrows

* Implement roof trim planes and ridge vent clipping

* Fix mansard roof and ridge vent placement

* Fix mansard merged roof cutouts

* Fix Dutch roof gable overhang

* Refactor roof segment, ridge vent, and surface geometry

Remove Dutch ridge axis abstraction and rework roof edit system,
ridge vent clipping geometry, and roof surface placement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Simplify Dutch roof shape

* Add Dutch roof gable top geometry controls

* Fix Dutch roof slope material slots

* Render dutch roof tops as double-sided faces

* Add auto ridge vent toggle to roof segments

Track ridge vent auto-generation via an `autoRidgeVent` metadata flag so
geometry changes only regenerate default vents when enabled, treating
legacy segments with generated vents as auto-enabled for back-compat.
Expose a panel toggle to opt in/out per segment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Snap new walls to the floor below

Feed the walls of the level directly beneath the active one into the
draft snap pipeline as extra references, so a new wall can align with
the floor below. They share the same local XZ origin, and the list is
kept separate from the current-level walls so the measurement HUD and
wall splitting only act on the active level.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Set Dutch roof shape defaults on type switch

Seed the Dutch shape parameters (waist width/height/length, top rake
thickness/length) with sensible defaults whenever a segment is created
as or switched to Dutch, so the gablet is well-formed regardless of
leftover values from the previous roof type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Use green accent for corner and endpoint snap markers

Color the corner/endpoint snap markers and the vertical cursor pillar
green across the 2D floorplan beacon, the 3D alignment guide dots, and
the wall snap beacon so snap targets read as a consistent accent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add magnetic wall snapping to the roof tool

Snap roof draft corners onto wall corners, midpoints, crossings, and
bodies on the active level and the floor below, reusing the wall tool's
snap pipeline so the beacon and coloring match. The cursor's ground
dot/ring is hidden while a wall snap is active to avoid overlapping the
beacon glyph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Update auto-generated Next.js route types path

Regenerated next-env.d.ts now references ./.next/dev/types/routes.d.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Show cutaway outline while dragging roof trim

Slice an untrimmed segment volume generated from the live node instead of
the registry mesh, whose CSG rebuild lags a few frames behind the drag and
may still hold placeholder geometry — so the section outline now renders
deterministically. Use LineBasicNodeMaterial so the outline draws under the
WebGPU pipeline, and export generateRoofSegmentGeometry for the slice source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Fill and clip roof trim cutaway, gate it to active drag

Add a violet silhouette fill behind the cutaway outline, extend the
section slicing to angled diagonal/corner trims via a generic vertical
cut plane, and clip each slice to its footprint span so the infinite
plane no longer sprouts stray lines across the rest of the roof. The
cutaway now renders only while a trim handle is being dragged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Separate and extend Dutch roof end slopes

Pull the Dutch hip end slopes out of the watertight shingle shell into
their own slab wedge so they can be reshaped independently, and extend
each end slope inward up its own hip plane until the top edge meets the
gablet's inner triangle. Refactor roof-segment shape geometry into a
shared roof-segment-shape module.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Render roof trim cutaway as a material-only section cut

Replace the triangle-mesh slicer with a CSG intersection of a thin slab
against the untrimmed roof shell, so the cutaway shows red only on real
material (wall + deck bands) and leaves the hollow attic empty. Add an
analytic surface-edge outline, style both solid red like a SketchUp
section, and make the cutaway persist whenever a segment is trimmed.

Keep the merged roof shell visible during trim editing (re-trimmed live
from each segment's drag override) instead of swapping in the per-segment
meshes, whose abutting end-cap faces showed as stray white planes the
commit never had. Extend each slab past free cut-line ends only — trimmed
ends clamp to the cut line — so the red section stays inside the trim box.

Re-export INTERSECTION from the viewer CSG surface for the editor.

* Outline roof cutaway by fill silhouette, restyle to destructive red

Derive the section-cut outline from the fill geometry's edges
(EdgesGeometry) so it traces the real cut shape — wall/deck band
boundaries and the hollow-attic edge — instead of just the top surface
line. Drop the fill to 85% opacity and recolor both fill and outline to
the app's destructive red, matching the delete/destructive UI.

* Include roof accessories in trim clipping and red cutaway

Roof accessories (chimney, vents, skylight, dormer, gutter, downspout,
solar-panel, cupola) now slice at the trim plane like the roof shell and
appear in the red section-cut while dragging a trim handle:

- Export clipGeometryBySegmentTrim from the viewer as a reusable
  segment-local trim-clip primitive.
- Add a shared useSegmentTrimClippedGeometry hook + TrimClippedMesh
  wrapper (nodes) that slice accessory geometry by the host segment's
  live trim override, so the cut tracks the drag.
- Wire the clip into all 11 accessory renderers, including skylight
  glass panes and dormer window glass/frame/sill.
- Feed every hosted accessory mesh into the editor's red cutaway, welding
  triangle-soup geometry (e.g. ridge vent) so CSG INTERSECTION yields a
  cross-section.
- Register skylight in the scene-graph tree-node map so it shows in the
  outliner when placed on a roof.

Co-Authored-By: Claude <noreply@anthropic.com>

* Add smooth spline fences with editable curve handles

Fences can now be drawn as one continuous Catmull-Rom/Bezier curve via an
optional `path` (+ per-point `tangents`), selectable in a Straight/Curved
mode toggle. Selected spline fences expose draggable control-point dots
(hexagon) and symmetric tangent handles (circle) joined by a violet line,
editable in both 2D plan and 3D. Side-move arrows are dropped for splines.

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix dutch roof ridge vent handling

* Fix Dutch ridge vent placement and support

* Fix Dutch roof trim artifacts

* Fix Dutch roof trim preview geometry

* Tag roof trim overlay meshes with EDITOR_LAYER

Child meshes relied on a parent group's layer, which three.js does not
propagate, so the trim section/rail/plane overlays rendered on the scene
layer — getting inked/SSGI-darkened and leaking into thumbnail exports.

Co-Authored-By: Claude <noreply@anthropic.com>

* Apply Biome cleanup

* fix(core): address Dutch roof review feedback

* chore: apply biome check cleanup

* fix(core): relax Dutch roof surface helper input

* fix

* Fix biome checks and dev verification

* fixes

* Remove unsupported Biome noShadow override

* Improve roof interactions and fence editing

* Fix fence drag and ridge vent default handling

* editor: drop wall-snap debug log, gate curved-fence finish hint on draft start

Remove the leftover TEMP DIAGNOSTIC console.log in the wall tool's onMove
hot path.

Curved fences commit on a closing gesture (double-click / Enter) rather than
per-click, so surface a 'Finish curve' hint in the fence HUD — but only once a
point has been placed and a curve is actually in flight. The draft point count
is published from SplineFenceDraft into a small ephemeral editor store
(useFenceCurveDraft) that the contextual helper reads, mirroring the existing
useSegmentDraftChain pattern.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
2026-06-30 16:41:13 -04:00

756 lines
34 KiB
TypeScript

'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
bboxAnchors,
bboxCornerAnchors,
emitter,
type FloorplanMoveTargetSession,
nodeRegistry,
pauseSceneHistory,
resumeSceneHistory,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../lib/sfx-bus'
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
// Figma-style alignment snap threshold. Meters in world space; 8cm gives
// a comfortable "magnetic" pull at default zoom without fighting the
// grid snap. Held fixed for v1 — a future revision can scale this with
// the SVG's units-per-pixel so the feel stays constant across zoom.
const ALIGNMENT_THRESHOLD_M = 0.08
/**
* Cursor-driven placement for registered kinds in the floor plan.
*
* Activates when `useEditor.movingNode` is set to a node whose kind is
* registered with `def.floorplan`. Two dispatch paths:
*
* 1. **`def.floorplanMoveTarget` present** (door / window / item):
* kind-specific 2D move handler with wall / ceiling / slab
* anchor logic. Pointer events feed `session.apply` which writes
* directly to `useScene`; pointer-up does the single-undo dance
* (revert→resume→re-apply) if `canCommit()` is true.
* 2. **Fallback — generic free-floating translate**: imperatively
* translates the rendered SVG entry on pointer-move, commits via
* `updateNode` on pointer-up. Used by shelf / spawn / fence /
* etc. whose move is "translate position on X/Z plane".
*
* Lives outside the `floorplan-panel.tsx` monolith. Coordinate
* conversion routes through the scene `<g>`'s `getScreenCTM` so
* cursor → meters accounts for pan / zoom / building rotation.
*/
export function FloorplanRegistryMoveOverlay() {
const movingNode = useMovingNode()
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
const def = movingNode ? nodeRegistry.get(movingNode.type) : null
const isActive = !!movingNode && !!def?.floorplan
const hasMoveTarget = !!def?.floorplanMoveTarget
useEffect(() => {
if (!isActive || !movingNode) return
const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
if (!scene) return
const toMeters = (clientX: number, clientY: number): [number, number] | null => {
const svg = scene.ownerSVGElement
if (!svg) return null
const ctm = scene.getScreenCTM()
if (!ctm) return null
const pt = svg.createSVGPoint()
pt.x = clientX
pt.y = clientY
const m = pt.matrixTransform(ctm.inverse())
return [m.x, m.y]
}
const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
// The scene's `<g>` only covers painted SVG elements, so hovers over
// empty grid background often target the parent SVG. Bounds keep the
// cursor active anywhere inside the floor-plan viewport.
const svg = scene.ownerSVGElement
if (!svg) return false
const rect = svg.getBoundingClientRect()
return (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
)
}
// ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
if (hasMoveTarget && def?.floorplanMoveTarget) {
const sceneNodes = useScene.getState().nodes
const session: FloorplanMoveTargetSession = (
def.floorplanMoveTarget as (a: {
node: AnyNode
nodes: Record<AnyNodeId, AnyNode>
}) => FloorplanMoveTargetSession
)({ node: movingNode, nodes: sceneNodes })
// Capture snapshots of every affected node BEFORE the first apply
// so the single-undo dance has a clean baseline to revert to.
const snapshots = session.affectedIds
.map((id) => sceneNodes[id])
.filter((n): n is AnyNode => !!n)
.map((n) => snapshotNode(n))
pauseSceneHistory(useScene)
let historyPaused = true
// The registry action menu's Move button portals to `document.body`,
// so the trigger click's pointer-up happens OUTSIDE the floor-plan
// scene and never reaches `onPointerUp` here. That means: the very
// first window-pointer-up the overlay sees is the user's intended
// commit click. No "click-to-enter" gesture to detect — the older
// flow used an orange "Move" dot rendered inside the slab itself,
// where the trigger click DID hit the overlay's listener and had
// to be consumed. That legacy flow is gone in the registry layer;
// all entries use the action menu now.
let hasMovedSinceStart = false
// Last resolved position of the moved node — drives the move "tick" SFX.
// Parity with the 3D move, which emits on any change of the resolved
// position (every snapping mode, not only grid).
let lastSnapKey: string | null = null
// Live cursor location — updated on EVERY pointermove (even over the 3D
// canvas) so R-key ownership can follow the pointer's CURRENT pane rather
// than the sticky `hasMovedSinceStart`. Without this, once the user touched
// the 2D pane the overlay claimed R forever and the 3D flip went dead.
let pointerOverFloorplan = false
const onPointerTrack = (event: PointerEvent) => {
pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
}
const onMove = (event: PointerEvent) => {
// Skip 3D-canvas / other-UI cursor moves so the overlay only
// tracks pointer events that actually correspond to a floor-plan
// location. The bounding-rect check (vs the legacy
// `target.closest('[data-floorplan-scene]')`) also picks up
// hovers over empty grid background — without it, the cursor
// only updated the shelf when it happened to brush over an
// existing SVG entry, leaving the move feeling "stuck" elsewhere.
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
const planPoint = toMeters(event.clientX, event.clientY)
if (!planPoint) return
hasMovedSinceStart = true
session.apply({
planPoint,
modifiers: {
shiftKey: event.shiftKey,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
},
})
// Move "tick" — same feedback the 3D move gives, which fires whenever the
// resolved position changes (any snapping mode, not just grid), so it
// ticks as the item lands on each new snapped/free position.
const movedId = session.affectedIds[0]
const moved = movedId ? useScene.getState().nodes[movedId] : undefined
const pos = (moved as { position?: [number, number, number] } | undefined)?.position
if (pos) {
const key = `${pos[0]},${pos[2]}`
if (key !== lastSnapKey) {
lastSnapKey = key
sfxEmitter.emit('sfx:grid-snap')
}
}
}
const commitFinalStateOrRevert = () => {
const commitValid = session.canCommit()
// Claim ownership of the drag teardown so the 3D move tool's
// unmount-time cleanup skips its restore-from-snapshot — see
// `movingNodeOrigin` in `use-editor.tsx`. Set here (before any
// `setMovingNode(null)`) so that by the time the 3D effect's
// cleanup runs the origin is observable in the store.
setMovingNodeOrigin('2d')
// Sessions with a `commit` hook own their atomic write (e.g.
// wall move emits creates + deletes + updates via the junction
// planner). For those we still do Phase 1 (revert to baseline)
// and Phase 2's resume — but Phase 2's write is delegated, and
// we skip the snapshot-diff finalUpdates path.
if (commitValid && session.commit) {
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) {
resumeSceneHistory(useScene)
historyPaused = false
}
session.commit()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
return
}
const sceneState = useScene.getState().nodes
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
for (const snap of snapshots) {
const current = sceneState[snap.id]
if (!current) continue
const data: Record<string, unknown> = {}
let changed = false
for (const [key, before] of Object.entries(snap.data)) {
const after = (current as unknown as Record<string, unknown>)[key]
if (!deepEqual(before, after)) {
data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
changed = true
}
}
if (changed) finalUpdates.push({ id: snap.id, data })
}
for (const snap of snapshots) {
const current = sceneState[snap.id]
if (!current || !isFreshPlacementMetadata((current as { metadata?: unknown }).metadata)) {
continue
}
const existing = finalUpdates.find((update) => update.id === snap.id)
const metadata = stripPlacementMetadataFlags((current as { metadata?: unknown }).metadata)
if (existing) {
existing.data.metadata = metadata
existing.data.visible = true
} else {
finalUpdates.push({
id: snap.id,
data: { metadata, visible: true },
})
}
}
if (commitValid && finalUpdates.length > 0) {
// Single-undo dance:
// 1. Revert to baseline while history is still paused.
// 2. Resume history.
// 3. Re-apply the final state — recorded as one tracked change.
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) {
resumeSceneHistory(useScene)
historyPaused = false
}
useScene.getState().updateNodes(finalUpdates)
sfxEmitter.emit('sfx:item-place')
// Re-select the moved node(s) — mirrors the legacy 3D move
// tool. The action menu cleared selection on Move click so
// selection-gated affordances (slab/ceiling boundary editor,
// etc.) would unmount during the drag; restoring it here
// brings them back at the new position.
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
} else {
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) {
resumeSceneHistory(useScene)
historyPaused = false
}
}
}
const onPointerUp = (event: PointerEvent) => {
if (event.button !== 0) return
// Bounding-rect check (see `isPointerOverFloorplanScene`) — same
// reason as `onMove`: commits should land for any pointer-up
// inside the SVG viewport, including empty grid background.
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
if (!hasMovedSinceStart) return
// Commit using the LAST pointermove's state — no re-apply at
// pointer-up coords. A previous version re-applied here to
// close a sub-pixel "drift" window when pointer-up fires
// without a preceding pointermove, but that re-apply also
// re-snaps: if the pointer-up coord crosses a grid boundary
// relative to the last pointermove, the snapped result flips
// to a different grid cell and the wall (or other moved node)
// visibly jumps from where it was painted during the drag to
// a different commit position. Trusting the last pointermove
// means "what you saw is what gets committed", which is the
// UX users expect — at the cost of a sub-pixel drift in the
// rare case where the OS fires pointerup with no preceding
// pointermove. Modern browsers reliably emit a final
// pointermove right before pointerup, so the trade-off lands
// on the side of WYSIWYG.
commitFinalStateOrRevert()
setMovingNode(null)
// Swallow the click event that follows this pointer-up — the
// floor-plan SVG's `handleBackgroundClick` would otherwise route
// it through `resolveFloorplanBackgroundSelection`, which clears
// the selection if the click resolved to empty space. We already
// set selection back to the moved node in `commitFinalStateOrRevert`;
// letting the background-click handler run would undo that for
// any commit click that doesn't happen to land directly on the
// node's hit-test geometry.
//
// The 3D mover doesn't need this because its grid-click fires
// via the emitter inside the R3F pointer event and can call
// `event.nativeEvent.stopPropagation()`; the 2D pointerup and
// the following click are separate DOM events, so we listen on
// window in the capture phase to intercept the click before any
// bubble-phase handler (the floor-plan SVG) sees it.
swallowNextClick()
}
const onKey = (event: KeyboardEvent) => {
// R flips a directional kind's facing mid-placement (door / window:
// front ↔ back). The session records the flip and re-runs its last
// apply so the 2D symbol updates immediately; kinds without a facing
// leave `flipSide` unset and R falls through to the global handler.
//
// Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
// move tool ALSO listens for R on `window`. We own R only while the
// cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
// 2D mover has actually engaged (`hasMovedSinceStart`); then we
// `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs
// first) so the 3D tool can't also flip. When the cursor is over the 3D
// pane we yield — the 3D tool owns R there. (The old sticky
// `hasMovedSinceStart`-only gate made the overlay claim R forever after
// the first 2D move, killing the 3D flip.)
if (event.key === 'r' || event.key === 'R') {
// Yield Cmd/Ctrl+R to the browser reload instead of flipping the side.
if (event.metaKey || event.ctrlKey) return
if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
if (event.repeat) return
const t = event.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
return
}
event.preventDefault()
event.stopImmediatePropagation()
session.flipSide()
sfxEmitter.emit('sfx:item-rotate')
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')
if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
emitter.emit('tool:cancel')
useScene.getState().deleteNode(movingNode.id as AnyNodeId)
if (historyPaused) {
resumeSceneHistory(useScene)
historyPaused = false
}
const liveTransforms = useLiveTransforms.getState()
const liveOverrides = useLiveNodeOverrides.getState()
for (const id of session.affectedIds) {
liveTransforms.clear(id)
liveOverrides.clear(id)
}
useAlignmentGuides.getState().clear()
setMovingNode(null)
return
}
// Revert untracked, then resume — no history entry.
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) {
resumeSceneHistory(useScene)
historyPaused = false
}
// Clear any live previews the session wrote. Slab / ceiling
// 2D move stages a translation delta in `useLiveTransforms`;
// wall move publishes `{ start, end, ... }` to
// `useLiveNodeOverrides`. Either way, leaving them in place
// after Esc would freeze the 2D / 3D view at the cancelled
// position.
const liveTransforms = useLiveTransforms.getState()
const liveOverrides = useLiveNodeOverrides.getState()
for (const id of session.affectedIds) {
liveTransforms.clear(id)
liveOverrides.clear(id)
}
// Restore selection cleared by the action menu's Move click.
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
setMovingNode(null)
}
window.addEventListener('pointermove', onPointerTrack)
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onPointerUp)
// Capture phase so this runs BEFORE the 3D move tool's bubble-phase R
// listener — when the cursor is over the 2D pane, `stopImmediatePropagation`
// then pre-empts it so only one handler flips.
window.addEventListener('keydown', onKey, true)
return () => {
window.removeEventListener('pointermove', onPointerTrack)
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey, 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.
//
// If `movingNodeOrigin === '3d'`, a 3D move tool finalised
// while our overlay was still mounted (split view); the live
// scene IS the committed state and reverting would stomp it.
// Otherwise (origin is `null` or `'2d'`) we own the teardown
// and revert any untracked apply() writes back to baseline.
//
// The two prior scenarios this block guarded against:
// - mid-drag unmount with apply() writes still present
// - 3D mover committing via `draftNode.commit` just before
// our unmount
// are now distinguished by the origin flag — no scene-state
// diff heuristic required.
if (historyPaused) {
if (hasMovedSinceStart) {
const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
if (!finalisedBy3D) {
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
}
}
resumeSceneHistory(useScene)
}
// Belt-and-suspenders: clear any live previews on abnormal
// unmount paths too. Slab / ceiling sessions write to
// `useLiveTransforms`; wall sessions write to
// `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
// tool's cleanup isn't there to clear them for us.
const liveTransforms = useLiveTransforms.getState()
const liveOverrides = useLiveNodeOverrides.getState()
for (const id of session.affectedIds) {
liveTransforms.clear(id)
liveOverrides.clear(id)
}
// Sessions that publish Figma-style alignment guides during `apply`
// (item / shelf / column) leave them in the store; this cleanup runs
// after every terminal path (commit + Esc both unmount via
// `setMovingNode(null)`), so clearing here drops any lingering guide.
useAlignmentGuides.getState().clear()
// 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()
}
}
// ── Path 2 — generic free-floating translate ────────────────────
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
if (!entry) return
// Polyline kinds (duct / pipe / lineset) carry a `path`, not a
// `position` — translating a `position` here would write a field their
// schema ignores and snap the run back. For those we move every path
// point by the cursor delta and commit the translated `path` instead.
// The reference origin is the path centre so the SVG `translate` delta
// matches the geometry's actual location (which isn't at [0,0,0]).
// Only 3D `[x, y, z]` polyline kinds (duct / pipe / lineset) are handled
// here. A spline fence also carries a `path`, but it is 2D (`[x, y]`) and
// moves through its own `floorplanMoveTarget`, so exclude shorter tuples.
const rawPath = (movingNode as { path?: unknown }).path
const originalPath =
Array.isArray(rawPath) && Array.isArray(rawPath[0]) && rawPath[0].length >= 3
? (rawPath as [number, number, number][]).map((p) => [...p] as [number, number, number])
: null
const originalPosition: [number, number, number] = originalPath
? (() => {
let cx = 0
let cz = 0
for (const p of originalPath) {
cx += p[0]
cz += p[2]
}
const n = originalPath.length || 1
return [cx / n, originalPath[0]?.[1] ?? 0, cz / n]
})()
: (((movingNode as unknown as { position?: [number, number, number] }).position ?? [
0, 0, 0,
]) as [number, number, number])
const isFreshPlacement = isFreshPlacementMetadata(
(movingNode as { metadata?: unknown }).metadata,
)
// SVG units in this floorplan map 1:1 to world meters, and the
// `<g data-node-id>` entry has no transform of its own when at rest,
// so its untransformed bbox IS the world-space footprint. Cache the
// moving entry's local bbox once (relative to originalPosition) and
// derive anchors at any proposed (sx, sz) by translating it.
const movingLocalBBox = entry.getBBox()
const candidateAnchors: AlignmentAnchor[] = []
const allEntries = scene.querySelectorAll('[data-node-id]')
for (const el of Array.from(allEntries)) {
const otherId = el.getAttribute('data-node-id')
if (!otherId || otherId === movingNode.id) continue
const b = (el as SVGGraphicsElement).getBBox()
// Skip only fully-degenerate (point) entries. A thin run (duct / pipe /
// lineset drawn as a line) has one zero dimension but is still a valid
// alignment target — its endpoints become line anchors.
if (b.width <= 0 && b.height <= 0) continue
candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
}
let lastSnapped: [number, number] | null = null
let dragAnchor: [number, number] | null = null
// Footprint bounding box drawn around the dragged entry — the 2D
// counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node
// reads the same in both views. Green wireframe rect over the entry's
// own bbox, translated in lockstep with it. The entry stays visible the
// whole drag (no hide-until-move) so it never appears to vanish.
const SVG_NS = 'http://www.w3.org/2000/svg'
const boxEl = document.createElementNS(SVG_NS, 'rect')
boxEl.setAttribute('x', String(movingLocalBBox.x))
boxEl.setAttribute('y', String(movingLocalBBox.y))
boxEl.setAttribute('width', String(movingLocalBBox.width))
boxEl.setAttribute('height', String(movingLocalBBox.height))
boxEl.setAttribute('fill', 'none')
boxEl.setAttribute('stroke', '#22c55e')
boxEl.setAttribute('stroke-width', '1.5')
boxEl.setAttribute('vector-effect', 'non-scaling-stroke')
boxEl.setAttribute('pointer-events', 'none')
scene.appendChild(boxEl)
const onMove = (event: PointerEvent) => {
// Same target guard as Path 1 — pointer must be over the floor
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
// plan coords.
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
const m = toMeters(event.clientX, event.clientY)
if (!m) return
// 1) Grid snap baseline. Fresh catalog placement is absolute under
// the cursor; existing moves preserve the cursor's grab offset. Grid
// follows the active snapping mode (Shift cycles it); raw cursor in
// any non-grid mode.
const gridStep = useEditor.getState().gridSnapStep
const snap = (value: number) =>
isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
const resolved = resolvePlanarCursorPosition({
cursor: [m[0], m[1]],
original: [originalPosition[0], originalPosition[2]],
anchor: dragAnchor,
mode: isFreshPlacement ? 'absolute' : 'relative',
snap,
})
dragAnchor = resolved.anchor
const [gridX, gridZ] = resolved.point
// 2) Alignment snap layered on top. Treat the grid-snapped point
// as the "proposed" position so alignment competes from a stable
// base rather than the raw cursor jitter. Alignment ("lines") follows
// the magnetic snapping mode — independent of grid; Alt is force-place,
// not a snap bypass.
let finalX = gridX
let finalZ = gridZ
if (isMagneticSnapActive() && candidateAnchors.length > 0) {
// Translate the cached local bbox to the proposed pos to get the
// moving anchors at that location. The entry's untransformed
// bbox is in world meters relative to the node's origin, so a
// simple translate suffices.
const dxProposed = gridX - originalPosition[0]
const dzProposed = gridZ - originalPosition[2]
// Corner-only for the moving node so it aligns by its edges, never
// its centreline — matching the placement tools and Path 1 move
// sessions. Candidates keep their full 9-point set (we DO want to
// align to a neighbour's centre / edge-midpoints).
const movingAnchors = bboxCornerAnchors(
movingNode.id,
movingLocalBBox.x + dxProposed,
movingLocalBBox.y + dzProposed,
movingLocalBBox.x + movingLocalBBox.width + dxProposed,
movingLocalBBox.y + movingLocalBBox.height + dzProposed,
)
// Local-frame resolve (anchors come from the building-local
// SVG `getBBox()`). Guides land in the editor-local alignment
// store, which the 2D FloorplanAlignmentGuideLayer renders
// inside the rotated scene <g>. The 3D pipeline uses a
// separate store, so frames stay isolated per surface.
const result = resolveAlignmentForFloorplanView({
moving: movingAnchors,
candidates: candidateAnchors,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
finalX += result.snap.dx
finalZ += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
const dx = finalX - originalPosition[0]
const dz = finalZ - originalPosition[2]
entry.setAttribute('transform', `translate(${dx} ${dz})`)
boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
lastSnapped = [finalX, finalZ]
}
const onPointerUp = (event: PointerEvent) => {
if (event.button !== 0) return
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
const snapped = lastSnapped
if (!snapped) return
const [sx, sz] = snapped
const [, oldY] = originalPosition
setMovingNodeOrigin('2d')
let selectedId = movingNode.id as AnyNodeId
if (originalPath) {
// Polyline kinds: shift every point by the committed delta and
// write `path`. Strip the fresh-placement flags on first drop.
const dx = sx - originalPosition[0]
const dz = sz - originalPosition[2]
const nextPath = originalPath.map(
([x, y, z]) => [x + dx, y, z + dz] as [number, number, number],
)
useScene.getState().updateNode(
movingNode.id as AnyNodeId,
(isFreshPlacement
? {
path: nextPath,
metadata: stripPlacementMetadataFlags(
(movingNode as { metadata?: unknown }).metadata,
),
visible: true,
}
: { path: nextPath }) as Partial<AnyNode>,
)
useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] })
entry.removeAttribute('transform')
useAlignmentGuides.getState().clear()
setMovingNode(null)
swallowNextClick()
return
}
if (isFreshPlacement) {
selectedId =
commitFreshPlacementSubtree(
movingNode.id as AnyNodeId,
{
position: [sx, oldY, sz],
metadata: stripPlacementMetadataFlags(
(movingNode as { metadata?: unknown }).metadata,
),
visible: true,
} as Partial<AnyNode>,
) ?? selectedId
} else {
useScene.getState().updateNode(
movingNode.id as AnyNodeId,
{
position: [sx, oldY, sz],
} as Partial<AnyNode>,
)
}
useViewer.getState().setSelection({ selectedIds: [selectedId] })
entry.removeAttribute('transform')
useAlignmentGuides.getState().clear()
setMovingNode(null)
swallowNextClick()
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setMovingNodeOrigin('2d')
if (isFreshPlacement) {
emitter.emit('tool:cancel')
const temporal = useScene.temporal.getState()
const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
if (wasTracking) temporal.pause()
useScene.getState().deleteNode(movingNode.id as AnyNodeId)
if (wasTracking) temporal.resume()
}
entry.removeAttribute('transform')
useAlignmentGuides.getState().clear()
setMovingNode(null)
}
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey)
entry.removeAttribute('transform')
// Always un-hide on teardown so a committed copy shows and a
// never-revealed entry doesn't leak a hidden style onto a reused node.
entry.style.visibility = ''
boxEl.remove()
useAlignmentGuides.getState().clear()
}
}, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
return null
}
// ── Snapshot helpers (shared shape with floorplan-registry-layer) ───
//
// Kept inline here to avoid a circular dependency through a shared
// utility module. If a third call site shows up, extract.
type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
function snapshotNode(node: AnyNode): NodeSnapshot {
const data: Record<string, unknown> = {}
for (const [key, value] of Object.entries(node)) {
if (key === 'id' || key === 'type' || key === 'object') continue
data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
}
return { id: node.id, data }
}
function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
return snapshots.map((s) => ({ id: s.id, data: s.data }))
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false
}
return true
}
if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
const aKeys = Object.keys(a as Record<string, unknown>)
const bKeys = Object.keys(b as Record<string, unknown>)
if (aKeys.length !== bKeys.length) return false
for (const key of aKeys) {
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
return false
}
}
return true
}
return false
}
function swallowNextClick() {
const swallowClick = (e: MouseEvent) => {
e.stopPropagation()
e.preventDefault()
window.removeEventListener('click', swallowClick, true)
}
window.addEventListener('click', swallowClick, true)
// Safety net: if no click fires (e.g. user dragged enough to suppress it),
// drop the listener on the next tick.
setTimeout(() => {
window.removeEventListener('click', swallowClick, true)
}, 0)
}