Merge origin/main (#407 placement restructure) into opening-proximity-guides

#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>
This commit is contained in:
Aymeric Rabot
2026-06-15 12:58:00 -04:00
co-authored by Claude Opus 4.8
59 changed files with 2212 additions and 807 deletions
@@ -283,6 +283,27 @@ export function FloorplanRegistryMoveOverlay() {
}
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.
//
// Only when THIS overlay is the active mover — i.e. the user has moved
// the pointer over the 2D pane (`hasMovedSinceStart`). The 3D move tool
// also listens for R while a door/window `movingNode` is placed (split
// / 3D-only view); gating on `hasMovedSinceStart` keeps the two from
// both flipping (or double-playing the cue) on a single R press.
if (event.key === 'r' || event.key === 'R') {
if (!(session.flipSide && hasMovedSinceStart)) return
const t = event.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
return
}
event.preventDefault()
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
@@ -27,6 +27,7 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
*/
export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
const node = usePlacementPreview((s) => s.node)
const parentNode = usePlacementPreview((s) => s.parentNode)
if (!node) return null
const builder = nodeRegistry.get(node.type)?.floorplan
@@ -37,11 +38,13 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
// `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
// and `parent: null` short-circuits the elevator's level walk) so the layer
// never subscribes to / bulk-reads the nodes map during render.
// `parentNode` is the synthetic wall for an off-wall door/window ghost so
// its builder draws the real swing-arc / pane symbol (see use-placement-preview).
const ctx = {
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
children: [],
siblings: [],
parent: null,
parent: parentNode ?? null,
viewState: undefined,
} as unknown as GeometryContext
@@ -506,7 +506,26 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (live) {
const floorPlaced = def?.capabilities?.floorPlaced
const hasPosition = Array.isArray((node as { position?: unknown }).position)
if (floorPlaced && hasPosition) {
if (node.type === 'door' || node.type === 'window') {
// Door / window movers publish WALL-LOCAL live transforms
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
// wiki/architecture/tools.md. The mover only writes
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
// updates the 3D mesh imperatively but never the scene node —
// without applying the live transform here the 2D symbol stays
// frozen while the cursor slides. Merge the wall-local position +
// rotation onto the node but KEEP `parentId` (the wall) so
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
// real swing-arc / pane symbol at the live spot.
const r = (node as { rotation?: unknown }).rotation
effectiveNode = {
...node,
position: live.position,
rotation: Array.isArray(r)
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
: r,
} as AnyNode
} else if (floorPlaced && hasPosition) {
effectiveNode = applyPositionLiveTransform(node, live)
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
const dx = live.position[0]
@@ -0,0 +1,128 @@
'use client'
import { collectLevelWallSegments, useScene, WALL_SNAP_DISTANCE_M } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { memo, useMemo } from 'react'
import useEditor from '../../../store/use-editor'
/**
* Dev-only 2D debug overlay for the opening (door / window) wall snap.
*
* The snap (`findClosestWallInPlan`) attaches to a wall when the cursor is
* within `WALL_SNAP_DISTANCE_M` of the wall's centerline, picking the
* nearest such wall. The set of points within that radius of a segment is a
* **capsule** (stadium): a band of half-width = the snap radius along the
* wall, with semicircular caps at each end. That capsule IS the wall's
* (normally invisible) hit target — so this layer draws it directly, one
* analytic `<path>` per wall, instead of sampling a grid (which produced the
* stair-stepped boundary the previous version showed). No per-point
* classification, so it's cheap regardless of plan size.
*
* Where two walls sit closer than 2× the radius their capsules overlap; the
* snap resolves the overlap to the nearer wall (the translucent fills just
* blend there — a darker patch reads as "either wall is in reach, nearest
* wins"). Drawing the true bisector-clipped cells would need the expensive
* per-point pass this rewrite removes, and the hit-area view is what the
* user asked for.
*
* Gated on `useEditor.show2dVoronoi` (developer menu). Renders inside the
* floor-plan scene `<g>`, so it shares the plan→SVG transform and the scene
* rotation with every other floor-plan layer.
*/
/** Stable hue per wall id so a wall keeps its colour across re-renders. */
function wallHue(id: string): number {
let hash = 0
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0
}
// Spread hues around the wheel with an offset that avoids a muddy
// red-orange clump for short, similar ids.
return (hash * 47) % 360
}
export const FloorplanVoronoiLayer = memo(function FloorplanVoronoiLayer() {
const show2dVoronoi = useEditor((s) => s.show2dVoronoi)
const selectedLevelId = useViewer((s) => s.selection.levelId)
// Recompute only when the wall geometry on this level changes — not on
// every scene write. Dragging a door updates the door node every frame;
// keying the build on this string means the capsule paths don't rebuild
// for openings, only for wall edits.
const nodes = useScene((s) => s.nodes)
const wallsKey = useMemo(() => {
if (!show2dVoronoi || !selectedLevelId) return ''
const segments = collectLevelWallSegments(nodes, selectedLevelId)
return segments
.map((s) => `${s.wall.id}:${s.start[0]},${s.start[1]},${s.end[0]},${s.end[1]}`)
.join('|')
}, [show2dVoronoi, selectedLevelId, nodes])
const walls = useMemo(() => {
if (!show2dVoronoi || !selectedLevelId || !wallsKey) return null
// Read nodes imperatively: `wallsKey` already encodes every wall change,
// so this memo is keyed on it rather than on the per-frame `nodes` ref.
const segments = collectLevelWallSegments(useScene.getState().nodes, selectedLevelId)
if (segments.length === 0) return null
const R = WALL_SNAP_DISTANCE_M
const r = R.toFixed(3)
return segments.map((s) => {
// Capsule outline. Normal = dir rotated +90° = (-dirY, dirX). Offset the
// segment endpoints ±R along the normal for the long sides, then a
// semicircular cap (radius R, sweep-flag 0 bulges outward past each end)
// joins them. Verified winding holds for every orientation because the
// whole construction is a rigid transform of the axis-aligned case.
const nx = -s.dirY
const ny = s.dirX
const ax = (s.start[0] + nx * R).toFixed(3)
const ay = (s.start[1] + ny * R).toFixed(3)
const bx = (s.end[0] + nx * R).toFixed(3)
const by = (s.end[1] + ny * R).toFixed(3)
const cx = (s.end[0] - nx * R).toFixed(3)
const cy = (s.end[1] - ny * R).toFixed(3)
const dx = (s.start[0] - nx * R).toFixed(3)
const dy = (s.start[1] - ny * R).toFixed(3)
const d = `M${ax} ${ay}L${bx} ${by}A${r} ${r} 0 0 0 ${cx} ${cy}L${dx} ${dy}A${r} ${r} 0 0 0 ${ax} ${ay}Z`
return {
wallId: s.wall.id,
d,
hue: wallHue(s.wall.id),
x1: s.start[0],
y1: s.start[1],
x2: s.end[0],
y2: s.end[1],
}
})
}, [show2dVoronoi, selectedLevelId, wallsKey])
if (!walls) return null
return (
<g className="floorplan-voronoi-debug" pointerEvents="none">
{walls.map(({ wallId, d, hue }) => (
<path
d={d}
fill={`hsla(${hue}, 80%, 55%, 0.22)`}
key={`hit-${wallId}`}
stroke={`hsl(${hue}, 85%, 50%)`}
strokeOpacity={0.5}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
))}
{walls.map(({ wallId, hue, x1, y1, x2, y2 }) => (
<line
key={`line-${wallId}`}
stroke={`hsl(${hue}, 85%, 42%)`}
strokeLinecap="round"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
x1={x1}
x2={x2}
y1={y1}
y2={y2}
/>
))}
</g>
)
})
@@ -10,6 +10,7 @@ import {
calculateLevelMiters,
DEFAULT_ANGLE_STEP,
type DoorNode,
DoorNode as DoorNodeSchema,
type ElevatorNode,
emitter,
type FenceNode,
@@ -45,7 +46,9 @@ import {
useLiveTransforms,
useScene,
type WallNode,
WallNode as WallNodeSchema,
type WindowNode,
WindowNode as WindowNodeSchema,
ZoneNode as ZoneNodeSchema,
type ZoneNode as ZoneNodeType,
} from '@pascal-app/core'
@@ -85,6 +88,7 @@ import { cn } from '../../lib/utils'
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
import usePlacementPreview from '../../store/use-placement-preview'
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
@@ -102,6 +106,7 @@ import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { snapToHalf } from '../tools/item/placement-math'
@@ -5501,6 +5506,70 @@ export function FloorplanPanel({
return 0
}, [isWindowBuildActive, movingNode, shiftPressed])
// Float the faithful door/window symbol at the cursor while it isn't over a
// wall (the off-wall placement ghost), by publishing a transient opening on a
// synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer`
// renders it through the real `def.floorplan` builder (swing arc / panes), so
// it reads as a real door/window, not a bare rectangle. Off any wall there's
// no orientation to inherit, so the synthetic wall runs along plan-X.
const showOpeningGhost = useCallback(
(planPoint: WallPlanPoint) => {
const isDoor = movingOpeningType === 'door' || (isDoorBuildActive && !movingOpeningType)
// Synthetic wall centred at the cursor; the opening sits at its midpoint.
const half =
(isDoor
? movingNode?.type === 'door'
? movingNode.width
: 0.9
: movingNode?.type === 'window'
? movingNode.width
: 1.5) /
2 +
0.5
const wall = WallNodeSchema.parse({
start: [planPoint[0] - half, planPoint[1]],
end: [planPoint[0] + half, planPoint[1]],
thickness: 0.1,
})
// Clone the moving opening (carries width / type / hinge / swing) onto the
// synthetic wall, or parse a default for build mode. position[0] = the
// along-wall midpoint so the symbol centres on the cursor.
const base =
movingNode?.type === 'door' || movingNode?.type === 'window'
? { ...movingNode }
: isDoor
? DoorNodeSchema.parse({})
: WindowNodeSchema.parse({})
const ghost = {
...base,
parentId: wall.id,
wallId: wall.id,
roofSegmentId: undefined,
roofFace: undefined,
position: [half, floorplanOpeningLocalY, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
} as AnyNode
usePlacementPreview.getState().set(ghost, wall)
},
[
DoorNodeSchema,
WallNodeSchema,
WindowNodeSchema,
floorplanOpeningLocalY,
isDoorBuildActive,
movingNode,
movingOpeningType,
],
)
// Drop the floating opening ghost whenever opening placement ends (commit,
// tool change, mode switch, cancel) or the active level changes, so a stale
// ghost never lingers on the wrong level.
useEffect(() => {
if (!isOpeningPlacementActive) usePlacementPreview.getState().clear()
}, [isOpeningPlacementActive])
useEffect(() => {
usePlacementPreview.getState().clear()
}, [levelId])
const isMarqueeSelectionToolActive =
mode === 'select' &&
floorplanSelectionTool === 'marquee' &&
@@ -8568,7 +8637,16 @@ export function FloorplanPanel({
// `wall:move` events the door / window placement tools listen for.
// Same reason `handleBackgroundPlacementClick` runs its opening
// branch before its grid catch-all.
if (isOpeningPlacementActive) {
//
// Only the pure BUILD case (a door/window tool armed with no
// `movingNode`) drives placement through these synthesized `wall:*`
// events. When a door/window `movingNode` is set — the community
// preset / catalog flow — `FloorplanRegistryMoveOverlay` owns 2D
// placement end-to-end via `def.floorplanMoveTarget` (faithful symbol,
// plan-space snap, single-undo commit, R-flip). Running both at once
// made them fight (R-flip overwritten on the next move, click-commit
// dropped), so the move case is excluded here.
if (isOpeningBuildActive && !isOpeningMoveActive) {
const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
@@ -8595,9 +8673,23 @@ export function FloorplanPanel({
} else {
emitter.emit('wall:move', wallEvent as any)
}
} else if (hoveredWallIdRef.current) {
emitFloorplanWallLeave(hoveredWallIdRef.current)
hoveredWallIdRef.current = null
// Snapped to a wall — the real on-wall draft is the preview; drop
// the loose free-follow ghost.
usePlacementPreview.getState().clear()
} else {
if (hoveredWallIdRef.current) {
emitFloorplanWallLeave(hoveredWallIdRef.current)
hoveredWallIdRef.current = null
}
// Off any wall — float the FAITHFUL door/window symbol (swing arc /
// panes) following the cursor, not a bare rectangle. The glyph
// builder needs a wall for `ctx.parent`, so we publish the opening on
// a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to
// `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it
// through the real `def.floorplan` builder. Shift bypasses grid snap.
const snappedPoint =
shiftPressed || event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
showOpeningGhost(snappedPoint)
}
return
}
@@ -8622,7 +8714,13 @@ export function FloorplanPanel({
// window are also registered kinds, but need wall events — see
// comment there). Wall build skips this so its own branch below
// updates local `draftEnd` state alongside the registry tool.
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
//
// A door/window MOVE (community preset) is owned by
// `FloorplanRegistryMoveOverlay`; `isRegistryToolBuildActive` is true
// for it (build mode + a registered `door`/`window` tool), so without
// this exclusion the catch-all would emit `grid:move` and re-drive the
// 3D MoveDoorTool's free-follow, fighting the overlay again.
if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) =>
@@ -8969,8 +9067,15 @@ export function FloorplanPanel({
isCeilingBuildActive,
isCeilingItemPlacementActive,
isFenceBuildActive,
isFloorplanGridInteractionActive,
isOpeningPlacementActive,
// Exclude the door/window MOVE case: `isRegistryToolBuildActive` makes the
// grid catch-all true for it, but the overlay owns its commit (its own
// pointerup). Letting the catch-all emit `grid:click` here would consume
// the commit click and fight the overlay.
isFloorplanGridInteractionActive: isFloorplanGridInteractionActive && !isOpeningMoveActive,
// Only the pure-build opening case (tool armed, no movingNode) commits via
// the synthesized `wall:click`; the move case (community preset) is owned
// by FloorplanRegistryMoveOverlay, which commits on its own pointerup.
isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
@@ -10412,6 +10517,13 @@ export function FloorplanPanel({
showGrid={showGrid}
/>
{/* Dev-only: draw each wall's opening-snap hit area (the
capsule of points within the snap radius of its centerline).
Gated on the developer-menu toggle. Painted right after the
grid so the translucent capsules sit under the wall / opening
glyphs. */}
<FloorplanVoronoiLayer />
<FloorplanReferenceFloorLayer
data={referenceFloorData}
opacity={referenceFloorOpacity}
@@ -6,6 +6,7 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import usePlacementPreview from '../../store/use-placement-preview'
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
@@ -152,6 +153,9 @@ export function useFloorplanBackgroundPlacement({
stopPropagation: () => {},
} as any)
}
// Drop the off-wall ghost on commit so it doesn't linger at the
// just-placed spot before the next pointer move re-evaluates.
usePlacementPreview.getState().clear()
return true
}
+17 -2
View File
@@ -30,6 +30,16 @@ export const useKeyboard = ({
return
}
// True while a door/window is being placed: either a fresh clone is moving
// (preset / duplicate path) or a door/window build tool is armed. The
// placement tool owns R/T then (flip the draft before commit), so the
// global selection-based R/T handler must stand down to avoid double-firing.
const isPlacingOpening = () => {
const ed = useEditor.getState()
if (ed.movingNode?.type === 'door' || ed.movingNode?.type === 'window') return true
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
}
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle shortcuts if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
@@ -171,11 +181,16 @@ export const useKeyboard = ({
}
}
}
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode && !isPlacingOpening()) {
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
// Doors use R to flip side (front ↔ back, rotation += π); their
// open/close toggle lives on E. Windows still use R to toggle
// their open/closed state.
//
// Skipped entirely while a door/window placement is active
// (`isPlacingOpening`): the placement tool owns R then (flip the draft
// before commit), and the user can have a node selected at the same
// time — without this guard both would fire (double flip + sfx).
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
@@ -225,7 +240,7 @@ export const useKeyboard = ({
sfxEmitter.emit('sfx:item-rotate')
}
}
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
// Rotate selected node counter-clockwise
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
+7
View File
@@ -369,6 +369,11 @@ type EditorState = {
// Development-only camera debug flag for inspecting underside geometry
allowUndergroundCamera: boolean
setAllowUndergroundCamera: (enabled: boolean) => void
// Development-only debug overlay: draw each wall's opening-snap hit area
// (the capsule of points within the snap radius of its centerline). Lets us
// see why a door/window snaps where it does.
show2dVoronoi: boolean
setShow2dVoronoi: (enabled: boolean) => void
// First-person walkthrough mode (street view)
isFirstPersonMode: boolean
_viewModeBeforeFirstPerson: ViewMode | null
@@ -955,6 +960,8 @@ const useEditor = create<EditorState>()(
set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
allowUndergroundCamera: false,
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
show2dVoronoi: false,
setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }),
isFirstPersonMode: false,
_viewModeBeforeFirstPerson: null as ViewMode | null,
setFirstPersonMode: (enabled) => {
@@ -18,14 +18,22 @@ type PlacementPreviewState = {
/** Transient preview node, already positioned + rotated at the (snapped,
* aligned) cursor. `null` when no placement is active. */
node: AnyNode | null
set(node: AnyNode | null): void
/** Optional synthetic parent for the preview's `def.floorplan` context.
* Door / window glyph builders need `ctx.parent` to be a wall to draw their
* real symbol (swing arc / panes); off any real wall we hand them a
* synthetic wall segment centred at the cursor so the floating ghost shows
* the faithful blueprint symbol instead of a bare rectangle. `null` for
* self-contained kinds (column / elevator). */
parentNode: AnyNode | null
set(node: AnyNode | null, parentNode?: AnyNode | null): void
clear(): void
}
const usePlacementPreview = create<PlacementPreviewState>((set) => ({
node: null,
set: (node) => set({ node }),
clear: () => set({ node: null }),
parentNode: null,
set: (node, parentNode = null) => set({ node, parentNode }),
clear: () => set({ node: null, parentNode: null }),
}))
export default usePlacementPreview