diff --git a/.gitignore b/.gitignore
index d01fc0bdd..38cfe8883 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,8 @@ og-test
# Worktrees
.worktrees
+
+# Local IDE/agent configs
+.claude/launch.json
+.claude/settings.local.json
+.vscode/launch.json
diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx
index e3b9e95e2..548c11efd 100644
--- a/apps/editor/components/viewer-toolbar.tsx
+++ b/apps/editor/components/viewer-toolbar.tsx
@@ -32,6 +32,7 @@ import {
EyeOff,
Footprints,
Grid2X2,
+ Magnet,
PenLine,
SlidersHorizontal,
Sparkles,
@@ -270,6 +271,8 @@ function DisplayMenu() {
const setEdges = useViewer((state) => state.setEdges)
const shadows = useViewer((state) => state.shadows)
const setShadows = useViewer((state) => state.setShadows)
+ const magneticSnap = useEditor((state) => state.magneticSnap)
+ const setMagneticSnap = useEditor((state) => state.setMagneticSnap)
const activeShading =
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
@@ -311,6 +314,13 @@ function DisplayMenu() {
)}
+ keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
+
+ Magnetic snap
+
+ {magneticSnap ? 'On' : 'Off'}
+
+
keepOpen(e, () => setShadows(!shadows))}>
Shadows
diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts
index 9edff1c7c..c4b7818fb 100644
--- a/apps/ifc-converter/next-env.d.ts
+++ b/apps/ifc-converter/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
index bf4574c37..ff85ff5ae 100644
--- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
@@ -29,6 +29,32 @@ export function resolveLevelId(node: AnyNode, nodes: Record): s
return 'default' // fallback for orphaned items
}
+/**
+ * Walks the parent chain of `nodeId` and returns the id of the first ancestor
+ * whose `type` is `'level'`, or `null` when no level ancestor exists (orphaned
+ * node, top-level building node, etc.). Unlike `resolveLevelId`, this variant:
+ *
+ * - accepts a node **id** rather than a resolved node, saving the caller a
+ * `nodes[id]` lookup when only the id is at hand.
+ * - returns `null` instead of the `'default'` fallback, which lets callers
+ * distinguish "genuinely has no level" from "is a level".
+ * - has a loop guard (16 iterations) so a corrupt parent-chain cycle cannot
+ * hang the frame loop.
+ */
+export function findLevelAncestorId(
+ nodeId: AnyNodeId,
+ nodes: Record,
+): string | null {
+ let current: AnyNode | undefined = nodes[nodeId]
+ let guard = 0
+ while (current && guard < 16) {
+ if (current.type === 'level') return current.id
+ current = current.parentId ? nodes[current.parentId] : undefined
+ guard += 1
+ }
+ return null
+}
+
/**
* Returns the building id that contains the given level, or `null` if
* the level is unparented or no enclosing building exists.
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 86dae30e9..02192d0c2 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -46,6 +46,7 @@ export {
} from './hooks/spatial-grid/floor-placed-elevation'
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
export {
+ findLevelAncestorId,
initSpatialGridSync,
resolveBuildingForLevel,
resolveLevelId,
@@ -111,7 +112,6 @@ export {
resetSceneHistoryPauseDepth,
resumeSceneHistory,
} from './store/history-control'
-export { default as useAlignmentGuides } from './store/use-alignment-guides'
export {
type ControlValue,
type DoorAnimationState,
diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts
index 8682f457e..347af4d72 100644
--- a/packages/core/src/registry/types.ts
+++ b/packages/core/src/registry/types.ts
@@ -982,6 +982,12 @@ export type Capabilities = {
*/
alignmentFootprint?: AlignmentFootprintConfig
roofAccessory?: RoofAccessoryConfig
+ /**
+ * Kind cuts a hole in the ceiling surface it is attached to (e.g. recessed
+ * downlights). The viewer's `CeilingSystem` calls this for each child of a
+ * ceiling to collect extra holes before triangulating. See `CeilingCutCapability`.
+ */
+ ceilingCut?: CeilingCutCapability
paint?: PaintCapability
/**
* Kind is placed by clicking on a wall (door, window). When set, the
@@ -1179,6 +1185,22 @@ export type RoofAccessoryConfig = {
buildCut?: (node: AnyNode, hostSegment: AnyNode) => BufferGeometry | null
}
+/**
+ * Capability for kinds that cut a hole in their host ceiling when the node is
+ * attached to a ceiling surface (e.g. recessed downlights). The viewer's
+ * `CeilingSystem` queries children of a ceiling for this capability and merges
+ * the returned polygons as extra holes before triangulating, keeping the viewer
+ * free of per-kind branching.
+ *
+ * Returns a rotated-rectangle footprint in ceiling-local [x, z] plan space —
+ * the same coordinate space as `CeilingNode.polygon` and `.holes`. Return
+ * `null` when this particular instance should not cut a hole (e.g. a
+ * non-recessed variant of the same kind).
+ */
+export type CeilingCutCapability = {
+ buildCeilingHole: (node: AnyNode) => Array<[number, number]> | null
+}
+
export type CapabilityCtx = { node: AnyNode }
export type MovableConfig = {
diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts
index fa8a04549..e37c3dc10 100644
--- a/packages/core/src/schema/nodes/item.ts
+++ b/packages/core/src/schema/nodes/item.ts
@@ -98,6 +98,11 @@ const assetSchema = z.object({
src: AssetUrl,
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
+ // Ceiling fixtures (e.g. recessed downlights) that embed *into* the ceiling
+ // rather than hang below it: the item seats flush with the ceiling plane
+ // (its body rising into the void above) and the ceiling is cut out around
+ // the item's footprint. Ignored unless `attachTo === 'ceiling'`.
+ recessed: z.boolean().optional(),
tags: z.array(z.string()).optional(),
// Function-axis tag slugs from the taxonomy. Drives the hierarchical
// Items-tab browse: a tree node matches when any of its descendant slugs
diff --git a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx
index 441eaa4fe..13016b02f 100644
--- a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx
+++ b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useAlignmentGuides } from '@pascal-app/core'
+import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { memo } from 'react'
import { formatMeasurement } from '../editor/measurement-pill'
diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx
index 18a8883b9..930df0efa 100644
--- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx
+++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx
@@ -12,11 +12,11 @@ import {
pauseSceneHistory,
resolveAlignment,
resumeSceneHistory,
- useAlignmentGuides,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
+import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
diff --git a/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx
new file mode 100644
index 000000000..85fd3bb92
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx
@@ -0,0 +1,75 @@
+'use client'
+
+import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
+import { memo } from 'react'
+import { useFloorplanRender } from './floorplan-render-context'
+
+/**
+ * "Magnetic" wall-snap beacon for the 2D floor plan — the top-down twin of the
+ * 3D `WallSnapBeaconLayer`. Subscribes to the shared `useWallSnapIndicator`
+ * store (published by the floor-plan wall draft + endpoint-move handlers) and
+ * draws a marker at the snap point whose shape tells you *what* it caught,
+ * matching the 3D glyphs:
+ *
+ * endpoint (corner) → square midpoint → triangle
+ * intersection → ✕ cross wall body (edge) → circle
+ *
+ * Indigo to match the 3D beacon and stay distinct from the red Figma alignment
+ * guides. Sizes are pixel-budgeted via `unitsPerPixel` so the marker stays a
+ * constant size on screen at any zoom. Mounted inside the `data-floorplan-scene`
+ * group so coordinates are world meters (XZ) 1:1, like the alignment guides.
+ */
+const COLOR = '#6366f1' // indigo-500 — matches the 3D beacon
+
+export const FloorplanSnapBeaconLayer = memo(function FloorplanSnapBeaconLayer() {
+ const point = useWallSnapIndicator((s) => s.point)
+ const ctx = useFloorplanRender()
+
+ if (!point) return null
+
+ const upp = ctx?.unitsPerPixel ?? 0.01
+ const m = 6 * upp // base half-size of the glyph in world meters
+ const stroke = 1.5 * upp
+
+ return (
+
+
+
+ )
+})
+
+function SnapMarker({
+ color,
+ kind,
+ m,
+ stroke,
+ x,
+ z,
+}: {
+ color: string
+ kind: WallSnapKind
+ m: number
+ stroke: number
+ x: number
+ z: number
+}) {
+ if (kind === 'endpoint') {
+ return
+ }
+ if (kind === 'midpoint') {
+ const t = m * 1.3
+ const points = `${x},${z - t} ${x - t},${z + t} ${x + t},${z + t}`
+ return
+ }
+ if (kind === 'intersection') {
+ const c = m * 1.4
+ return (
+
+
+
+
+ )
+ }
+ // 'wall' (edge / along-wall) → circle
+ return
+}
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
index 5646f8e23..bb5a1ae88 100644
--- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
@@ -14,12 +14,12 @@ import {
pauseSceneHistory,
resolveBuildingForLevel,
resumeSceneHistory,
- useAlignmentGuides,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
+import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import {
memo,
diff --git a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx
index 75574dc5b..ede233ba8 100644
--- a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx
+++ b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx
@@ -1,6 +1,7 @@
'use client'
-import { type AlignmentGuide, sceneRegistry, useAlignmentGuides } from '@pascal-app/core'
+import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core'
+import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index c4e95b303..37110a5d5 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -38,7 +38,6 @@ import {
StairSegmentNode as StairSegmentNodeSchema,
sampleWallCenterline,
sceneRegistry,
- useAlignmentGuides,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
@@ -48,6 +47,7 @@ import {
ZoneNode as ZoneNodeSchema,
type ZoneNode as ZoneNodeType,
} from '@pascal-app/core'
+import { useAlignmentGuides, useWallSnapIndicator } from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Command, Ruler } from 'lucide-react'
import {
@@ -87,6 +87,7 @@ import {
type FloorplanRenderContextValue,
FloorplanRenderProvider,
} from '../editor-2d/floorplan-render-context'
+import { FloorplanSnapBeaconLayer } from '../editor-2d/floorplan-snap-beacon-layer'
import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer'
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
@@ -132,6 +133,7 @@ import {
createWallOnCurrentLevel,
isSegmentLongEnough,
snapWallDraftPoint,
+ snapWallDraftPointDetailed,
snapPointToGrid as snapWallPointToGrid,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP,
@@ -6843,6 +6845,7 @@ export function FloorplanPanel() {
wallEndpointDragRef.current = null
setWallEndpointDraft(null)
setHoveredEndpointId(null)
+ useWallSnapIndicator.getState().clear()
}, [])
const clearWallCurveDrag = useCallback(() => {
wallCurveDragRef.current = null
@@ -6869,6 +6872,7 @@ export function FloorplanPanel() {
// Drop any Figma-style alignment guide a draft branch left behind so it
// doesn't linger after the tool deactivates / Esc / draft reset.
useAlignmentGuides.getState().clear()
+ useWallSnapIndicator.getState().clear()
}, [
clearFencePlacementDraft,
clearCeilingPlacementDraft,
@@ -7265,12 +7269,22 @@ export function FloorplanPanel() {
// Wall endpoint move: grid snap only (no 45° angle snap from the
// fixed corner — that's draft-only behaviour). Shift switches
// to the fine grid step for precision.
- const snappedPoint = snapWallDraftPoint({
+ const snapResult = snapWallDraftPointDetailed({
point: planPoint,
walls,
ignoreWallIds: [dragState.wallId],
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
+ magnetic: useEditor.getState().magneticSnap,
})
+ const snappedPoint = snapResult.point
+ // Magnetic beacon at the endpoint when it locked onto existing geometry.
+ useWallSnapIndicator
+ .getState()
+ .set(
+ snapResult.snap
+ ? { x: snappedPoint[0], z: snappedPoint[1], kind: snapResult.snap }
+ : null,
+ )
if (pointsEqual(dragState.currentPoint, snappedPoint)) {
return
@@ -8238,20 +8252,25 @@ export function FloorplanPanel() {
// wins outright — never pull the cursor off a corner the user is
// closing onto — so alignment runs ONLY when the wall snap left the
// point on the plain grid. Alt bypasses alignment.
- const gridStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
- const wallSnapped = snapWallDraftPoint({
+ const wallSnap = snapWallDraftPointDetailed({
point: planPoint,
walls,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
+ magnetic: useEditor.getState().magneticSnap,
})
- const gridBase = snapWallPointToGrid(planPoint, gridStep)
- const lockedToWall = wallSnapped[0] !== gridBase[0] || wallSnapped[1] !== gridBase[1]
+ const wallSnapped = wallSnap.point
+ // Locked onto existing geometry (corner / midpoint / crossing / edge) →
+ // that snap wins, so skip Figma alignment and stand the beacon there.
+ const lockedToWall = wallSnap.snap !== null
let snappedPoint = wallSnapped
if (lockedToWall) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey })
}
+ useWallSnapIndicator
+ .getState()
+ .set(wallSnap.snap ? { x: snappedPoint[0], z: snappedPoint[1], kind: wallSnap.snap } : null)
// Emit `grid:move` so the registry-driven wall tool's 3D preview
// tracks the cursor. The local draftEnd update below is what
@@ -8506,6 +8525,19 @@ export function FloorplanPanel() {
phase,
toPoint2D,
})
+ // Wall-commit snap for the placement hook. Mirrors the move-preview branch:
+ // it honours the Magnetic snap toggle so a click never snaps to geometry the
+ // preview didn't (keeps 2D commit consistent with the preview and with 3D).
+ const snapWallDraftPointMagnetic = useCallback(
+ (args: {
+ point: WallPlanPoint
+ walls: WallNode[]
+ start?: WallPlanPoint
+ angleSnap?: boolean
+ step?: number
+ }) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }),
+ [],
+ )
const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({
activePolygonDraftPoints,
ceilingDraftPoints,
@@ -8539,7 +8571,7 @@ export function FloorplanPanel() {
setRoofDraftStart,
shiftPressed,
snapPolygonDraftPoint,
- snapWallDraftPoint,
+ snapWallDraftPoint: snapWallDraftPointMagnetic,
toPoint2D,
walls,
})
@@ -10047,6 +10079,11 @@ export function FloorplanPanel() {
paint on top of node geometry. */}
+ {/* "Magnetic" wall-snap beacon — per-kind glyph at the active
+ draft / endpoint-move snap point. Same store + coord space as
+ the alignment guides. */}
+
+
{
@@ -141,7 +145,7 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
raycaster.setFromCamera(ndc, camera)
const hit = new Vector3()
if (!raycaster.ray.intersectPlane(plane, hit)) return
- const startHit = hit.clone()
+ const startLocal = hit.clone().applyMatrix4(frameInv)
document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick')
@@ -160,9 +164,14 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
raycaster.setFromCamera(ndc, camera)
const moveHit = new Vector3()
if (!raycaster.ray.intersectPlane(plane, moveHit)) return
+ const moveLocal = moveHit.applyMatrix4(frameInv)
const snap = !e.shiftKey && step > 0
- const dx = snap ? Math.round((moveHit.x - startHit.x) / step) * step : moveHit.x - startHit.x
- const dz = snap ? Math.round((moveHit.z - startHit.z) / step) * step : moveHit.z - startHit.z
+ const dx = snap
+ ? Math.round((moveLocal.x - startLocal.x) / step) * step
+ : moveLocal.x - startLocal.x
+ const dz = snap
+ ? Math.round((moveLocal.z - startLocal.z) / step) * step
+ : moveLocal.z - startLocal.z
// Ticker on each grid-cell crossing, like single-item placement.
if (snap && (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz)) {
@@ -210,7 +219,9 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
}
useLiveNodeOverrides.getState().setMany(overrideEntries)
- setLiveDelta([dx, dz])
+ // Gizmo rides the group in world space; map the level-frame delta back out.
+ const worldDelta = new Vector3(dx, 0, dz).applyMatrix4(frame).sub(frameOrigin)
+ setLiveDelta([worldDelta.x, worldDelta.z])
}
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx
index 671ebbdf9..3d7c434ee 100644
--- a/packages/editor/src/components/editor/group-rotate-handle.tsx
+++ b/packages/editor/src/components/editor/group-rotate-handle.tsx
@@ -20,6 +20,7 @@ import {
collectParticipants,
computeGroupBox,
expandToComponent,
+ levelFrame,
type Vec2,
type Vec3,
} from './group-transform-shared'
@@ -140,13 +141,17 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
// Snapshot the selected participants + connected wall/fence neighbours whose
// shared endpoints must follow the rotation (so junctions stay welded).
- const { starts, links } = collectParticipants(
- ids,
- useScene.getState().nodes,
- useViewer.getState().selection.levelId,
- )
+ const levelId = useViewer.getState().selection.levelId
+ const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId)
if (starts.length === 0) return
+ // Placements live in the level frame; the world pivot must be converted into
+ // it before orbiting positions, or a rotated building displaces the centre.
+ // The swept angle is frame-invariant (both frames differ by a constant yaw,
+ // which cancels in `angleOf(move) - angleOf(start)`), so it's still measured
+ // in world against `center` — keeping the world-space guide overlay correct.
+ const localCenter = center.clone().applyMatrix4(levelFrame(levelId).inverse)
+
// Horizontal drag plane at the pivot; bearing measured around the pivot.
const plane = new Plane(new Vector3(0, 1, 0), -center.y)
const angleOf = (p: Vector3) => Math.atan2(p.z - center.z, p.x - center.x)
@@ -155,7 +160,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
// participant's anchor point(s).
let spread = 0
const reach = (x: number, z: number) => {
- spread = Math.max(spread, Math.hypot(x - center.x, z - center.z))
+ spread = Math.max(spread, Math.hypot(x - localCenter.x, z - localCenter.z))
}
for (const s of starts) {
if (s.kind === 'endpoint') {
@@ -207,9 +212,9 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const cos = Math.cos(delta)
const sin = Math.sin(delta)
const rot = (x: number, z: number): Vec2 => {
- const dx = x - center.x
- const dz = z - center.z
- return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
+ const dx = x - localCenter.x
+ const dz = z - localCenter.z
+ return [localCenter.x + dx * cos - dz * sin, localCenter.z + dx * sin + dz * cos]
}
const overrideEntries: Array]> = []
const liveTransforms = useLiveTransforms.getState()
diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts
index 8252d3f9b..5a000dcea 100644
--- a/packages/editor/src/components/editor/group-transform-shared.ts
+++ b/packages/editor/src/components/editor/group-transform-shared.ts
@@ -5,7 +5,7 @@ import {
resolveBuildingForLevel,
sceneRegistry,
} from '@pascal-app/core'
-import { Box3 } from 'three'
+import { Box3, Matrix4 } from 'three'
// Shared plumbing for the group transform gizmos (rotate + move). Both operate
// on the same multi-selection: classify each participant by how its placement
@@ -219,10 +219,26 @@ export function expandToComponent(
return Array.from(included)
}
+// Frozen world matrix of the level group + its inverse. A node's placement
+// (`position` / `start` / `end`) is stored in its parent level's frame, but the
+// gizmos raycast the ground plane in WORLD space. When the building is rotated
+// those frames diverge, so a world-space drag delta / rotation pivot must be
+// converted into the level frame before it's written back to placements —
+// otherwise the move drifts off-axis from the cursor and the rotation orbits a
+// displaced centre. Returns identity matrices when the level isn't mounted, which
+// collapses to the old behaviour (world == local) for an unrotated building.
+export function levelFrame(levelId: string | null): { matrix: Matrix4; inverse: Matrix4 } {
+ const obj = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : null
+ if (!obj) return { matrix: new Matrix4(), inverse: new Matrix4() }
+ obj.updateWorldMatrix(true, false)
+ const matrix = obj.matrixWorld.clone()
+ return { matrix, inverse: matrix.clone().invert() }
+}
+
// World-space union bounding box of the selected meshes, or null if none are
-// mounted yet. Levels are axis-aligned in XZ, so world XZ coincides with each
-// node's level-local placement — letting callers transform placement directly
-// against box-derived points without per-node frame conversion.
+// mounted yet. Used to place the gizmos (which are portalled to the scene root,
+// so they live in world space); placement writes convert back to the level frame
+// via `levelFrame`.
export function computeGroupBox(ids: string[]): Box3 | null {
const box = new Box3()
const tmp = new Box3()
diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx
new file mode 100644
index 000000000..278cab1df
--- /dev/null
+++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx
@@ -0,0 +1,150 @@
+'use client'
+
+import { sceneRegistry } from '@pascal-app/core'
+import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
+import { useViewer } from '@pascal-app/viewer'
+import { useFrame } from '@react-three/fiber'
+import { memo, useRef } from 'react'
+import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three'
+import { MeshBasicNodeMaterial } from 'three/webgpu'
+import { EDITOR_LAYER } from '../../lib/constants'
+
+/**
+ * "Magnetic" wall-snap beacon for the 3D editor — a vertical marker that
+ * stands at the draft / move endpoint while it's locked onto existing wall
+ * geometry. It's the spatial cue from the design reference: a standing pillar
+ * so you can see *where* the snap caught even at an angle, plus a floor marker
+ * whose shape tells you *what* it caught (CAD-style osnap glyphs):
+ *
+ * endpoint (corner) → square midpoint → triangle
+ * intersection → ✕ cross wall body (edge) → circle
+ *
+ * Subscribes to the shared `useWallSnapIndicator` store (published by the wall
+ * draft + endpoint-move tools). Shares the indigo accent of
+ * `Alignment3DGuideLayer` for visual consistency.
+ *
+ * The point carries only XZ (building-local plan coords); like the alignment
+ * guides it's lifted to the active level's building-local Y each frame so it
+ * stands on the floor being edited when floors are stacked. Mounted inside
+ * ToolManager's building-local group.
+ */
+
+const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent
+const BEACON_HEIGHT = 2.5 // world-meter height of the pillar
+const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
+const MARKER = 0.13 // world-meter base size of the floor glyph
+const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid
+
+// Shared resources — one material + unit geometries, so snap churn during a
+// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer).
+const beaconMaterial = new MeshBasicNodeMaterial({
+ color: BEACON_COLOR,
+ depthTest: false,
+ depthWrite: false,
+ toneMapped: false,
+ transparent: true,
+ opacity: 0.9,
+})
+const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8)
+// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as
+// a flat plate; circles/triangles lie flat via an X rotation at the mesh.
+const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1)
+const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3)
+const CIRCLE_GEOMETRY = new CircleGeometry(1, 28)
+
+export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
+ const point = useWallSnapIndicator((s) => s.point)
+ const levelId = useViewer((s) => s.selection.levelId)
+ const groupRef = useRef(null)
+
+ // Track the active level's building-local Y each frame so the beacon stands
+ // on the floor being edited, not the building base — same source the
+ // alignment guide layer and `grid.tsx` read.
+ useFrame(() => {
+ const group = groupRef.current
+ if (!group) return
+ const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
+ group.position.y = levelMesh ? levelMesh.position.y : 0
+ })
+
+ if (!point) return null
+ return (
+
+
+
+
+ )
+})
+
+/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */
+function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) {
+ const y = FLOOR_LIFT
+ if (kind === 'endpoint') {
+ return (
+
+ )
+ }
+ if (kind === 'midpoint') {
+ return (
+
+ )
+ }
+ if (kind === 'intersection') {
+ // Two crossed bars → an ✕, the universal "crossing" glyph.
+ return (
+ <>
+
+
+ >
+ )
+ }
+ // 'wall' (edge / along-wall) → circle
+ return (
+
+ )
+}
diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.tsx
index 8708b7e4f..094ca94bc 100644
--- a/packages/editor/src/components/systems/roof/roof-edit-system.tsx
+++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx
@@ -13,7 +13,11 @@ import * as THREE from 'three'
// recomputation per segment when the user actually wants it back.
function makeEmptySegmentGeometry(): THREE.BufferGeometry {
const g = new THREE.BufferGeometry()
- g.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
+ // Three zero-vertices (one degenerate, invisible triangle), not an empty
+ // attribute: in accessory-reveal mode the segments-wrapper is shown, so these
+ // meshes are drawn. An empty position (count 0) leaves WebGPU vertex buffer
+ // slot 0 unbound and the draw is rejected, poisoning the command encoder.
+ g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
// Match the four material slots the roof-segment renderer's material
// array expects (0=top, 1=side, 2=interior, 3=shingle). Without these
// groups, mesh.material is a single-material lookup that mismatches
diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx
index b535dc318..3a1db9b29 100644
--- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx
+++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx
@@ -7,9 +7,9 @@ import {
type GridEvent,
type LevelNode,
resolveAlignment,
- useAlignmentGuides,
useScene,
} from '@pascal-app/core'
+import { useAlignmentGuides } from '@pascal-app/editor'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts
index 191e49075..6ab28510e 100644
--- a/packages/editor/src/components/tools/item/placement-strategies.ts
+++ b/packages/editor/src/components/tools/item/placement-strategies.ts
@@ -370,16 +370,19 @@ export const ceilingStrategy = {
// use the ceiling hit's local position rather than world position.
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
- const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
+ // Recessed fixtures seat flush with the ceiling plane (body rising into the
+ // void above); everything else hangs its full height below the ceiling.
+ const seatY = ctx.asset.recessed ? 0 : -itemHeight
+ const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
return {
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
nodeUpdate: {
- position: [x, -itemHeight, z],
+ position: [x, seatY, z],
parentId: event.node.id,
},
cursorRotationY: 0,
- gridPosition: [x, -itemHeight, z],
+ gridPosition: [x, seatY, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
stopPropagation: true,
}
@@ -401,10 +404,13 @@ export const ceilingStrategy = {
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
- const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
+ // Recessed fixtures seat flush with the ceiling plane (body rising into the
+ // void above); everything else hangs its full height below the ceiling.
+ const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
+ const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
return {
- gridPosition: [x, -itemHeight, z],
+ gridPosition: [x, seatY, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
cursorRotationY: 0,
nodeUpdate: null,
diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
index b675a8166..2e6ca3d3b 100644
--- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
+++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
@@ -14,13 +14,13 @@ import {
resolveLevelId,
type ShelfEvent,
sceneRegistry,
- useAlignmentGuides,
useLiveTransforms,
useScene,
useSpatialQuery,
type WallEvent,
type WallNode,
} from '@pascal-app/core'
+import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
index b864a68f2..e13410a42 100644
--- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
+++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
@@ -15,10 +15,10 @@ import {
resolveAlignment,
sceneRegistry,
spatialGridManager,
- useAlignmentGuides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
+import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx
index 982854ee9..a26af9f9a 100644
--- a/packages/editor/src/components/tools/roof/roof-tool.tsx
+++ b/packages/editor/src/components/tools/roof/roof-tool.tsx
@@ -10,9 +10,9 @@ import {
resolveAlignment,
sceneRegistry,
snapScalar,
- useAlignmentGuides,
useScene,
} from '@pascal-app/core'
+import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx
index 1b5259032..6f2761bc9 100644
--- a/packages/editor/src/components/tools/stair/stair-tool.tsx
+++ b/packages/editor/src/components/tools/stair/stair-tool.tsx
@@ -12,9 +12,9 @@ import {
StairNode,
StairSegmentNode,
syncAutoStairOpenings,
- useAlignmentGuides,
useScene,
} from '@pascal-app/core'
+import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx
index e5f8e01fe..d9009345c 100644
--- a/packages/editor/src/components/tools/tool-manager.tsx
+++ b/packages/editor/src/components/tools/tool-manager.tsx
@@ -10,6 +10,7 @@ import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
+import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
import { ElevatorTool } from './elevator/elevator-tool'
import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool'
@@ -281,6 +282,8 @@ export const ToolManager: React.FC = () => {
tools above. Lives inside the building-local group so the
building-local guide coords render at the right world position. */}
+ {/* "Magnetic" beacon at the active wall-draft snap point. */}
+
>
)
diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts
index 1fa496043..84e4084f4 100644
--- a/packages/editor/src/components/tools/wall/wall-drafting.ts
+++ b/packages/editor/src/components/tools/wall/wall-drafting.ts
@@ -3,10 +3,7 @@ import {
type AnyNodeId,
type DoorNode,
getScaledDimensions,
- getWallCurveFrameAt,
- getWallCurveLength,
type ItemNode,
- isCurvedWall,
useScene,
type WallNode,
WallNode as WallSchema,
@@ -15,19 +12,30 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
-
-export type WallPlanPoint = [number, number]
+import {
+ distanceSquared,
+ findWallSnapTarget,
+ findWallSpecialPointSnap,
+ projectPointOntoWall,
+ WALL_JOIN_SNAP_RADIUS,
+ type WallDraftSnapResult,
+ type WallPlanPoint,
+} from './wall-snap-geometry'
+
+// The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
+// existing importers (fence drafting, the editor barrel) keep their paths.
+export {
+ findWallSnapTarget,
+ WALL_JOIN_SNAP_RADIUS,
+ type WallDraftSnapKind,
+ type WallDraftSnapResult,
+ type WallPlanPoint,
+} from './wall-snap-geometry'
export const WALL_GRID_STEP = 0.5
// Smallest available grid snap. Used as a precision-mode step (Shift +
// drag) so a drag can land on values the regular grid skips.
export const WALL_FINE_GRID_STEP = 0.05
-export const WALL_JOIN_SNAP_RADIUS = 0.35
-// Generous radius for snapping to an *existing* wall's endpoint while
-// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
-// is the strongest user intent (closing a polygon, attaching to a
-// corner) and the cursor never lands pixel-perfect on a corner.
-export const WALL_ENDPOINT_SNAP_RADIUS = 0.7
export const WALL_MIN_LENGTH = 0.01
const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
@@ -43,12 +51,6 @@ type WallSplitIntersection = {
point: WallPlanPoint
}
-function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
- const dx = a[0] - b[0]
- const dz = a[1] - b[1]
- return dx * dx + dz * dz
-}
-
export function getSegmentGridStep(): number {
return useEditor.getState().gridSnapStep
}
@@ -83,24 +85,6 @@ export function getWallAngleSnapStep(step = getSegmentGridStep()): number {
return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
}
-function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
- const [x1, z1] = wall.start
- const [x2, z2] = wall.end
- const dx = x2 - x1
- const dz = z2 - z1
- const lengthSquared = dx * dx + dz * dz
- if (lengthSquared < 1e-9) {
- return null
- }
-
- const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
- if (t <= 0 || t >= 1) {
- return null
- }
-
- return [x1 + dx * t, z1 + dz * t]
-}
-
function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
const { id: _id, parentId: _parentId, children, ...rest } = wall
@@ -332,84 +316,7 @@ function splitWallIfNeeded(
}
}
-export function findWallSnapTarget(
- point: WallPlanPoint,
- walls: WallNode[],
- options?: { ignoreWallIds?: string[]; radius?: number },
-): WallPlanPoint | null {
- const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
- const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
- let bestTarget: WallPlanPoint | null = null
- let bestDistanceSquared = Number.POSITIVE_INFINITY
-
- for (const wall of walls) {
- if (ignoreWallIds.has(wall.id)) {
- continue
- }
-
- const candidates: Array = [wall.start, wall.end]
-
- if (isCurvedWall(wall)) {
- const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
- for (let index = 0; index <= sampleCount; index += 1) {
- const frame = getWallCurveFrameAt(wall, index / sampleCount)
- candidates.push([frame.point.x, frame.point.y])
- }
- } else {
- candidates.push(projectPointOntoWall(point, wall))
- }
- for (const candidate of candidates) {
- if (!candidate) {
- continue
- }
-
- const candidateDistanceSquared = distanceSquared(point, candidate)
- if (
- candidateDistanceSquared > radiusSquared ||
- candidateDistanceSquared >= bestDistanceSquared
- ) {
- continue
- }
-
- bestTarget = candidate
- bestDistanceSquared = candidateDistanceSquared
- }
- }
-
- return bestTarget
-}
-
-/**
- * Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a
- * generous radius. Use this before `findWallSnapTarget` so the strong
- * "attach to an existing wall corner" intent isn't accidentally pushed
- * out of range by an interim grid snap that moved the cursor away from
- * the endpoint.
- */
-function findWallEndpointFromRaw(
- point: WallPlanPoint,
- walls: WallNode[],
- ignoreWallIds?: string[],
-): WallPlanPoint | null {
- const ignored = new Set(ignoreWallIds ?? [])
- const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
- let best: WallPlanPoint | null = null
- let bestDistSq = Number.POSITIVE_INFINITY
-
- for (const wall of walls) {
- if (ignored.has(wall.id)) continue
- for (const corner of [wall.start, wall.end] as WallPlanPoint[]) {
- const d = distanceSquared(point, corner)
- if (d <= radiusSquared && d < bestDistSq) {
- best = corner
- bestDistSq = d
- }
- }
- }
- return best
-}
-
-export function snapWallDraftPoint(args: {
+type SnapWallDraftArgs = {
point: WallPlanPoint
walls: WallNode[]
start?: WallPlanPoint
@@ -417,16 +324,33 @@ export function snapWallDraftPoint(args: {
ignoreWallIds?: string[]
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
step?: number
-}): WallPlanPoint {
- const { point, walls, start, angleSnap = false, ignoreWallIds, step: overrideStep } = args
+ /**
+ * Magnetic snapping to existing wall geometry (corners, midpoints,
+ * crossings, wall bodies). When `false`, only grid/angle snap applies and
+ * `snap` is always `null`. Defaults to `true` so callers that don't care
+ * keep the prior behaviour.
+ */
+ magnetic?: boolean
+}
- // Endpoint of an existing wall wins outright when the cursor is
- // anywhere within `WALL_ENDPOINT_SNAP_RADIUS` — closing a polygon or
- // attaching to a corner should "just work" without needing
- // pixel-perfect aim. Done from the raw cursor so it isn't masked by
- // an interim grid snap that nudged us out of range.
- const endpointSnap = findWallEndpointFromRaw(point, walls, ignoreWallIds)
- if (endpointSnap) return endpointSnap
+export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
+ const {
+ point,
+ walls,
+ start,
+ angleSnap = false,
+ ignoreWallIds,
+ step: overrideStep,
+ magnetic = true,
+ } = args
+
+ // Discrete special points (corner / midpoint / crossing) are taken from the
+ // raw cursor so an interim grid snap can't mask them. A corner always wins,
+ // then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
+ if (magnetic) {
+ const special = findWallSpecialPointSnap(point, walls, ignoreWallIds)
+ if (special) return special
+ }
const step = overrideStep ?? getSegmentGridStep()
const angleStep = getWallAngleSnapStep(step)
@@ -435,11 +359,16 @@ export function snapWallDraftPoint(args: {
? snapPointTo45Degrees(start, point, step, angleStep)
: snapPointToGrid(point, step)
- return (
- findWallSnapTarget(basePoint, walls, {
- ignoreWallIds,
- }) ?? basePoint
- )
+ if (magnetic) {
+ const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds })
+ if (wallSnap) return { point: wallSnap, snap: 'wall' }
+ }
+
+ return { point: basePoint, snap: null }
+}
+
+export function snapWallDraftPoint(args: SnapWallDraftArgs): WallPlanPoint {
+ return snapWallDraftPointDetailed(args).point
}
export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts
new file mode 100644
index 000000000..4aadb80d7
--- /dev/null
+++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, test } from 'bun:test'
+import type { WallNode } from '@pascal-app/core'
+import {
+ findWallSnapTarget,
+ findWallSpecialPointSnap,
+ type WallPlanPoint,
+} from './wall-snap-geometry'
+
+function makeWall(start: WallPlanPoint, end: WallPlanPoint, id?: string): WallNode {
+ return {
+ object: 'node',
+ id: (id ?? `wall_${start.join('_')}_${end.join('_')}`) as WallNode['id'],
+ type: 'wall',
+ name: 'Wall',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ start,
+ end,
+ thickness: 0.1,
+ frontSide: 'unknown',
+ backSide: 'unknown',
+ } as unknown as WallNode
+}
+
+describe('findWallSpecialPointSnap', () => {
+ test('snaps to a wall corner (endpoint) when near it', () => {
+ const walls = [makeWall([0, 0], [4, 0])]
+ const result = findWallSpecialPointSnap([0.1, 0.1], walls)
+ expect(result?.snap).toBe('endpoint')
+ expect(result?.point).toEqual([0, 0])
+ })
+
+ test('snaps to a wall midpoint when near it (not a corner)', () => {
+ const walls = [makeWall([0, 0], [4, 0])]
+ const result = findWallSpecialPointSnap([2.1, 0.2], walls)
+ expect(result?.snap).toBe('midpoint')
+ expect(result?.point).toEqual([2, 0])
+ })
+
+ test('snaps to the crossing of two walls (intersection)', () => {
+ // A runs along z=0; B crosses it at x=1. B's own midpoint is [1,1], far
+ // from the crossing, so the snap is the intersection, not a midpoint.
+ const walls = [makeWall([0, 0], [4, 0], 'a'), makeWall([1, -1], [1, 3], 'b')]
+ const result = findWallSpecialPointSnap([1.1, 0.1], walls)
+ expect(result?.snap).toBe('intersection')
+ expect(result?.point[0]).toBeCloseTo(1, 6)
+ expect(result?.point[1]).toBeCloseTo(0, 6)
+ })
+
+ test('corner wins over a midpoint when both are in range', () => {
+ const walls = [makeWall([0, 0], [1, 0])]
+ const result = findWallSpecialPointSnap([0.9, 0.1], walls)
+ expect(result?.snap).toBe('endpoint')
+ expect(result?.point).toEqual([1, 0])
+ })
+
+ test('returns null when no special point is in range', () => {
+ const walls = [makeWall([0, 0], [4, 0])]
+ // Near the wall body but far from corner/midpoint — that's an edge snap,
+ // handled separately by findWallSnapTarget, not a special point.
+ expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull()
+ })
+})
+
+describe('findWallSnapTarget (edge / along-wall)', () => {
+ test('projects onto a wall body within range', () => {
+ const walls = [makeWall([0, 0], [4, 0])]
+ const result = findWallSnapTarget([1.2, 0.1], walls)
+ expect(result?.[0]).toBeCloseTo(1.2, 6)
+ expect(result?.[1]).toBeCloseTo(0, 6)
+ })
+
+ test('returns null when too far from any wall', () => {
+ const walls = [makeWall([0, 0], [4, 0])]
+ expect(findWallSnapTarget([1.2, 2], walls)).toBeNull()
+ })
+})
diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts
new file mode 100644
index 000000000..049e7a118
--- /dev/null
+++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts
@@ -0,0 +1,270 @@
+// Pure geometry for "magnetic" wall-draft snapping — no store / viewer / React
+// deps, so it's unit-testable in isolation. Coordinates are XZ plan points
+// (building-local meters). `wall-drafting.ts` layers grid/angle snapping and
+// scene access on top of these primitives.
+
+import {
+ getWallCurveFrameAt,
+ getWallCurveLength,
+ isCurvedWall,
+ type WallNode,
+} from '@pascal-app/core'
+
+export type WallPlanPoint = [number, number]
+
+/** Which kind of existing-geometry snap produced a drafted point. */
+export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
+
+export type WallDraftSnapResult = {
+ point: WallPlanPoint
+ /**
+ * Set when `point` locked onto existing wall geometry (a corner, midpoint,
+ * crossing, or wall body) rather than a plain grid/angle position. This is
+ * the "magnetic" snap the beacon visualises; `null` for grid/angle-only.
+ */
+ snap: WallDraftSnapKind | null
+}
+
+export const WALL_JOIN_SNAP_RADIUS = 0.35
+// Generous radius for snapping to an *existing* wall's endpoint while
+// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
+// is the strongest user intent (closing a polygon, attaching to a
+// corner) and the cursor never lands pixel-perfect on a corner.
+export const WALL_ENDPOINT_SNAP_RADIUS = 0.7
+// Discrete "special point" snaps taken from the raw cursor (like the
+// endpoint snap) but slightly tighter — a corner is the strongest intent,
+// a midpoint / crossing is the next tier down.
+export const WALL_MIDPOINT_SNAP_RADIUS = 0.5
+export const WALL_INTERSECTION_SNAP_RADIUS = 0.5
+
+export function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
+ const dx = a[0] - b[0]
+ const dz = a[1] - b[1]
+ return dx * dx + dz * dz
+}
+
+export function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
+ const [x1, z1] = wall.start
+ const [x2, z2] = wall.end
+ const dx = x2 - x1
+ const dz = z2 - z1
+ const lengthSquared = dx * dx + dz * dz
+ if (lengthSquared < 1e-9) {
+ return null
+ }
+
+ const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
+ if (t <= 0 || t >= 1) {
+ return null
+ }
+
+ return [x1 + dx * t, z1 + dz * t]
+}
+
+export function findWallSnapTarget(
+ point: WallPlanPoint,
+ walls: WallNode[],
+ options?: { ignoreWallIds?: string[]; radius?: number },
+): WallPlanPoint | null {
+ const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
+ const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
+ let bestTarget: WallPlanPoint | null = null
+ let bestDistanceSquared = Number.POSITIVE_INFINITY
+
+ for (const wall of walls) {
+ if (ignoreWallIds.has(wall.id)) {
+ continue
+ }
+
+ const candidates: Array = [wall.start, wall.end]
+
+ if (isCurvedWall(wall)) {
+ const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
+ for (let index = 0; index <= sampleCount; index += 1) {
+ const frame = getWallCurveFrameAt(wall, index / sampleCount)
+ candidates.push([frame.point.x, frame.point.y])
+ }
+ } else {
+ candidates.push(projectPointOntoWall(point, wall))
+ }
+ for (const candidate of candidates) {
+ if (!candidate) {
+ continue
+ }
+
+ const candidateDistanceSquared = distanceSquared(point, candidate)
+ if (
+ candidateDistanceSquared > radiusSquared ||
+ candidateDistanceSquared >= bestDistanceSquared
+ ) {
+ continue
+ }
+
+ bestTarget = candidate
+ bestDistanceSquared = candidateDistanceSquared
+ }
+ }
+
+ return bestTarget
+}
+
+/**
+ * Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a
+ * generous radius. Use this before `findWallSnapTarget` so the strong
+ * "attach to an existing wall corner" intent isn't accidentally pushed
+ * out of range by an interim grid snap that moved the cursor away from
+ * the endpoint.
+ */
+export function findWallEndpointFromRaw(
+ point: WallPlanPoint,
+ walls: WallNode[],
+ ignoreWallIds?: string[],
+): WallPlanPoint | null {
+ const ignored = new Set(ignoreWallIds ?? [])
+ const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
+ let best: WallPlanPoint | null = null
+ let bestDistSq = Number.POSITIVE_INFINITY
+
+ for (const wall of walls) {
+ if (ignored.has(wall.id)) continue
+ for (const corner of [wall.start, wall.end] as WallPlanPoint[]) {
+ const d = distanceSquared(point, corner)
+ if (d <= radiusSquared && d < bestDistSq) {
+ best = corner
+ bestDistSq = d
+ }
+ }
+ }
+ return best
+}
+
+/** Midpoint of a wall — curve midpoint for curved walls, segment midpoint otherwise. */
+function wallMidpoint(wall: WallNode): WallPlanPoint {
+ if (isCurvedWall(wall)) {
+ const frame = getWallCurveFrameAt(wall, 0.5)
+ return [frame.point.x, frame.point.y]
+ }
+ return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
+}
+
+/** Nearest wall midpoint to the raw cursor, within `WALL_MIDPOINT_SNAP_RADIUS`. */
+export function findWallMidpointFromRaw(
+ point: WallPlanPoint,
+ walls: WallNode[],
+ ignoreWallIds?: string[],
+): WallPlanPoint | null {
+ const ignored = new Set(ignoreWallIds ?? [])
+ const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2
+ let best: WallPlanPoint | null = null
+ let bestDistSq = Number.POSITIVE_INFINITY
+
+ for (const wall of walls) {
+ if (ignored.has(wall.id)) continue
+ const mid = wallMidpoint(wall)
+ const d = distanceSquared(point, mid)
+ if (d <= radiusSquared && d < bestDistSq) {
+ best = mid
+ bestDistSq = d
+ }
+ }
+ return best
+}
+
+/** Crossing point of two straight segments, or null if they don't intersect within both. */
+function segmentIntersection(
+ a1: WallPlanPoint,
+ a2: WallPlanPoint,
+ b1: WallPlanPoint,
+ b2: WallPlanPoint,
+): WallPlanPoint | null {
+ const rx = a2[0] - a1[0]
+ const rz = a2[1] - a1[1]
+ const sx = b2[0] - b1[0]
+ const sz = b2[1] - b1[1]
+ const denom = rx * sz - rz * sx
+ if (Math.abs(denom) < 1e-9) return null // parallel / collinear
+
+ const qpx = b1[0] - a1[0]
+ const qpz = b1[1] - a1[1]
+ const t = (qpx * sz - qpz * sx) / denom
+ const u = (qpx * rz - qpz * rx) / denom
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null
+
+ return [a1[0] + t * rx, a1[1] + t * rz]
+}
+
+/**
+ * Nearest point where two existing straight walls cross, within
+ * `WALL_INTERSECTION_SNAP_RADIUS`. Curved walls are skipped. O(n²) over the
+ * level's walls — fine at editor scale.
+ */
+export function findWallIntersectionFromRaw(
+ point: WallPlanPoint,
+ walls: WallNode[],
+ ignoreWallIds?: string[],
+): WallPlanPoint | null {
+ const ignored = new Set(ignoreWallIds ?? [])
+ const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall))
+ const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2
+ let best: WallPlanPoint | null = null
+ let bestDistSq = Number.POSITIVE_INFINITY
+
+ for (let i = 0; i < straight.length; i += 1) {
+ for (let j = i + 1; j < straight.length; j += 1) {
+ const crossing = segmentIntersection(
+ straight[i]!.start,
+ straight[i]!.end,
+ straight[j]!.start,
+ straight[j]!.end,
+ )
+ if (!crossing) continue
+ const d = distanceSquared(point, crossing)
+ if (d <= radiusSquared && d < bestDistSq) {
+ best = crossing
+ bestDistSq = d
+ }
+ }
+ }
+ return best
+}
+
+/** Pick the candidate nearest to `point`, ignoring nulls. */
+function nearestCandidate(
+ point: WallPlanPoint,
+ candidates: Array,
+): WallDraftSnapResult | null {
+ let best: WallDraftSnapResult | null = null
+ let bestDistSq = Number.POSITIVE_INFINITY
+ for (const candidate of candidates) {
+ if (!candidate) continue
+ const d = distanceSquared(point, candidate.point)
+ if (d < bestDistSq) {
+ best = candidate
+ bestDistSq = d
+ }
+ }
+ return best
+}
+
+/**
+ * Discrete "special point" snap from the raw cursor, in priority order:
+ * 1. corners (endpoints) — strongest intent, largest radius
+ * 2. midpoints / crossings — next tier; the nearer of the two wins
+ * A corner within range always wins over a midpoint/crossing. Returns null
+ * when no special point is in range (caller falls back to grid/edge snap).
+ */
+export function findWallSpecialPointSnap(
+ point: WallPlanPoint,
+ walls: WallNode[],
+ ignoreWallIds?: string[],
+): WallDraftSnapResult | null {
+ const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds)
+ if (endpoint) return { point: endpoint, snap: 'endpoint' }
+
+ const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds)
+ const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds)
+ return nearestCandidate(point, [
+ midpoint && { point: midpoint, snap: 'midpoint' },
+ intersection && { point: intersection, snap: 'intersection' },
+ ])
+}
diff --git a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx
index 8c1963af5..d0a42e71a 100644
--- a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx
+++ b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx
@@ -628,6 +628,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
rotation: [0, 0, 0],
scale: [1, 1, 1],
attachTo: 'ceiling',
+ recessed: true,
interactive: {
controls: [
{ kind: 'toggle' },
diff --git a/packages/editor/src/hooks/use-ceiling-events.ts b/packages/editor/src/hooks/use-ceiling-events.ts
new file mode 100644
index 000000000..c6f9960b3
--- /dev/null
+++ b/packages/editor/src/hooks/use-ceiling-events.ts
@@ -0,0 +1,175 @@
+import {
+ type AnyNodeId,
+ type CeilingEvent,
+ type CeilingNode,
+ emitter,
+ resolveLevelId,
+ sceneRegistry,
+ useScene,
+} from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { useThree } from '@react-three/fiber'
+import { useEffect, useRef } from 'react'
+import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
+import useEditor from '../store/use-editor'
+
+const UP = new Vector3(0, 1, 0)
+
+/** Ray-casting point-in-polygon on ceiling-local [x, z] tuples. */
+function pointInPolygon(x: number, z: number, polygon: Array<[number, number]>): boolean {
+ let inside = false
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const a = polygon[i]!
+ const b = polygon[j]!
+ const intersects =
+ a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]
+ if (intersects) inside = !inside
+ }
+ return inside
+}
+
+/**
+ * Reliable pointer events for placing ceiling-attached items.
+ *
+ * Mirrors {@link useGridEvents} (the floor path): rather than relying on the
+ * thin, single-sided `ceiling-grid` overlay mesh to be hit by R3F's raycaster —
+ * which misses from the "wrong" camera side, near polygon edges/holes, or while
+ * the overlay is mid-reveal, dropping the commit click even though the green box
+ * still shows — it intersects a math plane at each ceiling's height (double-sided,
+ * so it hits from above or below) and point-in-polygon tests the hit. Emits
+ * `ceiling:enter/move/leave/click` so both the placement coordinator and the 2D
+ * floor-plan item preview keep working.
+ *
+ * Active only while a ceiling-attached item is being placed or moved. The
+ * `ceiling-grid` mesh no longer emits these events (see `CeilingRenderer`), so
+ * this is the single, reliable source.
+ */
+export function useCeilingEvents() {
+ const { camera, gl } = useThree()
+ const raycaster = useRef(new Raycaster())
+ const pointer = useRef(new Vector2())
+ const plane = useRef(new Plane(UP.clone(), 0))
+ const worldHit = useRef(new Vector3())
+ const meshWorld = useRef(new Vector3())
+ const hoveredRef = useRef(null)
+
+ useEffect(() => {
+ const canvas = gl.domElement
+
+ const isActive = (): boolean => {
+ const ed = useEditor.getState()
+ if (ed.selectedItem?.attachTo === 'ceiling') return true
+ const moving = ed.movingNode
+ return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling'
+ }
+
+ type Hit = { node: CeilingNode; mesh: Object3D; world: Vector3; local: Vector3 }
+
+ // The ceiling on the active level whose plane the cursor ray crosses inside
+ // its polygon, nearest the camera.
+ const pick = (nativeEvent: PointerEvent | MouseEvent): Hit | null => {
+ const activeLevelId = useViewer.getState().selection.levelId
+ if (!activeLevelId) return null
+
+ const rect = canvas.getBoundingClientRect()
+ pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
+ pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
+ raycaster.current.setFromCamera(pointer.current, camera)
+
+ const nodes = useScene.getState().nodes
+ const ceilingIds = sceneRegistry.byType.ceiling
+ if (!ceilingIds) return null
+
+ let best: Hit | null = null
+ let bestDist = Number.POSITIVE_INFINITY
+
+ for (const id of ceilingIds) {
+ const node = nodes[id as AnyNodeId] as CeilingNode | undefined
+ if (!node || node.type !== 'ceiling' || node.polygon.length < 3) continue
+ if (resolveLevelId(node, nodes) !== activeLevelId) continue
+ const mesh = sceneRegistry.nodes.get(id)
+ if (!mesh) continue
+
+ mesh.getWorldPosition(meshWorld.current)
+ plane.current.set(UP, -meshWorld.current.y)
+ if (!raycaster.current.ray.intersectPlane(plane.current, worldHit.current)) continue
+
+ const local = mesh.worldToLocal(worldHit.current.clone())
+ if (!pointInPolygon(local.x, local.z, node.polygon)) continue
+
+ const dist = raycaster.current.ray.origin.distanceToSquared(worldHit.current)
+ if (dist < bestDist) {
+ bestDist = dist
+ best = { node, mesh, world: worldHit.current.clone(), local }
+ }
+ }
+ return best
+ }
+
+ const buildEvent = (hit: Hit, nativeEvent: PointerEvent | MouseEvent): CeilingEvent => ({
+ node: hit.node,
+ position: [hit.world.x, hit.world.y, hit.world.z],
+ localPosition: [hit.local.x, hit.local.y, hit.local.z],
+ normal: [0, 1, 0],
+ object: hit.mesh,
+ stopPropagation: () => {},
+ nativeEvent: nativeEvent as never,
+ })
+
+ const emitLeave = (nativeEvent: PointerEvent | MouseEvent) => {
+ const id = hoveredRef.current
+ if (!id) return
+ hoveredRef.current = null
+ const node = useScene.getState().nodes[id as AnyNodeId] as CeilingNode | undefined
+ const mesh = sceneRegistry.nodes.get(id)
+ if (!node || !mesh) return
+ emitter.emit(
+ 'ceiling:leave',
+ buildEvent(
+ { node, mesh, world: meshWorld.current.clone(), local: new Vector3() },
+ nativeEvent,
+ ),
+ )
+ }
+
+ const onMove = (e: PointerEvent) => {
+ if (!isActive()) {
+ emitLeave(e)
+ return
+ }
+ const hit = pick(e)
+ if (!hit) {
+ emitLeave(e)
+ return
+ }
+ const ev = buildEvent(hit, e)
+ if (hoveredRef.current !== hit.node.id) {
+ if (hoveredRef.current) emitLeave(e)
+ hoveredRef.current = hit.node.id
+ emitter.emit('ceiling:enter', ev)
+ }
+ emitter.emit('ceiling:move', ev)
+ }
+
+ const onClick = (e: MouseEvent) => {
+ if (useViewer.getState().cameraDragging) return
+ if (e.button !== 0) return
+ if (!isActive()) return
+ const hit = pick(e)
+ if (!hit) return
+ emitter.emit('ceiling:click', buildEvent(hit, e))
+ }
+
+ const onPointerLeave = (e: PointerEvent) => emitLeave(e)
+
+ canvas.addEventListener('pointermove', onMove)
+ canvas.addEventListener('click', onClick)
+ canvas.addEventListener('pointerleave', onPointerLeave)
+
+ return () => {
+ canvas.removeEventListener('pointermove', onMove)
+ canvas.removeEventListener('click', onClick)
+ canvas.removeEventListener('pointerleave', onPointerLeave)
+ }
+ }, [camera, gl])
+}
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index b020e309c..0acf48bef 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -102,7 +102,10 @@ export {
snapPointToGrid,
snapScalarToGrid,
snapWallDraftPoint,
+ snapWallDraftPointDetailed,
WALL_FINE_GRID_STEP,
+ type WallDraftSnapKind,
+ type WallDraftSnapResult,
type WallPlanPoint,
} from './components/tools/wall/wall-drafting'
// `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the
@@ -234,6 +237,7 @@ export {
// nodes` so they don't need their own copy / their own tailwind-merge
// dependency.
export { cn } from './lib/utils'
+export { default as useAlignmentGuides } from './store/use-alignment-guides'
export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
export type {
@@ -255,3 +259,8 @@ export {
export { default as usePlacementPreview } from './store/use-placement-preview'
export { useUploadStore } from './store/use-upload'
export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
+export {
+ default as useWallSnapIndicator,
+ type WallSnapKind,
+ type WallSnapPoint,
+} from './store/use-wall-snap-indicator'
diff --git a/packages/editor/src/lib/floorplan/apply-alignment.ts b/packages/editor/src/lib/floorplan/apply-alignment.ts
index a180f68f0..f65754adf 100644
--- a/packages/editor/src/lib/floorplan/apply-alignment.ts
+++ b/packages/editor/src/lib/floorplan/apply-alignment.ts
@@ -3,9 +3,9 @@ import {
type AlignmentGuide,
collectAlignmentAnchors,
resolveAlignment,
- useAlignmentGuides,
useScene,
} from '@pascal-app/core'
+import { useAlignmentGuides } from '@pascal-app/editor'
/**
* Fixed Figma-style alignment threshold (meters) for floor-plan placement /
diff --git a/packages/core/src/store/use-alignment-guides.ts b/packages/editor/src/store/use-alignment-guides.ts
similarity index 91%
rename from packages/core/src/store/use-alignment-guides.ts
rename to packages/editor/src/store/use-alignment-guides.ts
index ae4991fb0..4f6f3eff6 100644
--- a/packages/core/src/store/use-alignment-guides.ts
+++ b/packages/editor/src/store/use-alignment-guides.ts
@@ -3,8 +3,8 @@
// guides on pointermove; the renderer (a 2D / 3D guide layer) subscribes
// and draws them. Both sides clear on commit, cancel, and unmount.
+import type { AlignmentGuide } from '@pascal-app/core'
import { create } from 'zustand'
-import type { AlignmentGuide } from '../services/alignment'
type AlignmentGuidesState = {
guides: AlignmentGuide[]
diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx
index a49ed91e2..1d6d20799 100644
--- a/packages/editor/src/store/use-editor.tsx
+++ b/packages/editor/src/store/use-editor.tsx
@@ -344,6 +344,11 @@ type EditorState = {
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep
setGridSnapStep: (step: GridSnapStep) => void
+ // Magnetic snapping while drafting — snaps wall endpoints onto existing
+ // wall corners / wall bodies (the "magnetic" beacon). Independent of grid
+ // snap. On by default; toggled from the Display menu.
+ magneticSnap: boolean
+ setMagneticSnap: (enabled: boolean) => void
showReferenceFloor: boolean
toggleReferenceFloor: () => void
setShowReferenceFloor: (show: boolean) => void
@@ -386,6 +391,7 @@ type PersistedEditorLayoutState = Pick<
| 'splitOrientation'
| 'floorplanSelectionTool'
| 'gridSnapStep'
+ | 'magneticSnap'
| 'showReferenceFloor'
| 'referenceFloorOffset'
| 'referenceFloorOpacity'
@@ -408,6 +414,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
splitOrientation: 'horizontal',
floorplanSelectionTool: 'click',
gridSnapStep: 0.5,
+ magneticSnap: true,
showReferenceFloor: false,
referenceFloorOffset: 1,
referenceFloorOpacity: 0.35,
@@ -520,6 +527,8 @@ function normalizePersistedEditorLayoutState(
gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
? (state?.gridSnapStep as GridSnapStep)
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
+ // Default on: only an explicit persisted `false` disables it.
+ magneticSnap: state?.magneticSnap !== false,
showReferenceFloor: state?.showReferenceFloor === true,
referenceFloorOffset:
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -876,6 +885,8 @@ const useEditor = create()(
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
setGridSnapStep: (step) => set({ gridSnapStep: step }),
+ magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
+ setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
toggleReferenceFloor: () =>
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
@@ -965,6 +976,7 @@ const useEditor = create()(
splitOrientation: state.splitOrientation,
floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep,
+ magneticSnap: state.magneticSnap,
showReferenceFloor: state.showReferenceFloor,
referenceFloorOffset: state.referenceFloorOffset,
referenceFloorOpacity: state.referenceFloorOpacity,
diff --git a/packages/editor/src/store/use-wall-snap-indicator.ts b/packages/editor/src/store/use-wall-snap-indicator.ts
new file mode 100644
index 000000000..cd23e29cb
--- /dev/null
+++ b/packages/editor/src/store/use-wall-snap-indicator.ts
@@ -0,0 +1,32 @@
+// Ephemeral store for the "magnetic" wall-snap beacon shown during wall
+// drafting. The wall tool writes the active snap point on pointermove when
+// the draft endpoint locks onto existing wall geometry (a corner or a point
+// along a wall); the 3D beacon layer subscribes and draws a vertical marker
+// there. Cleared on commit, cancel, and unmount — same lifecycle as
+// `use-alignment-guides`.
+
+import { create } from 'zustand'
+
+/** Which kind of wall geometry the draft point snapped to. */
+export type WallSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
+
+export type WallSnapPoint = {
+ /** Building-local plan coordinates (XZ meters). */
+ x: number
+ z: number
+ kind: WallSnapKind
+}
+
+type WallSnapIndicatorState = {
+ point: WallSnapPoint | null
+ set(point: WallSnapPoint | null): void
+ clear(): void
+}
+
+const useWallSnapIndicator = create((set) => ({
+ point: null,
+ set: (point) => set({ point }),
+ clear: () => set({ point: null }),
+}))
+
+export default useWallSnapIndicator
diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx
index 4a10c87e4..cdd252612 100644
--- a/packages/nodes/src/ceiling/move-tool.tsx
+++ b/packages/nodes/src/ceiling/move-tool.tsx
@@ -9,11 +9,16 @@ import {
polygonAnchors,
resolveAlignment,
sceneRegistry,
- useAlignmentGuides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
-import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
+import {
+ CursorSphere,
+ markToolCancelConsumed,
+ triggerSFX,
+ useAlignmentGuides,
+ useEditor,
+} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type * as THREE from 'three'
diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx
index cf624bd8b..4e0e9fe75 100644
--- a/packages/nodes/src/ceiling/renderer.tsx
+++ b/packages/nodes/src/ceiling/renderer.tsx
@@ -5,23 +5,21 @@ import {
getMaterialPresetByRef,
resolveMaterial,
useRegistry,
+ useScene,
} from '@pascal-app/core'
import {
createSurfaceRoleMaterial,
NodeRenderer,
resolveSurfaceColor,
- useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
-import { useEffect, useMemo, useRef } from 'react'
-import { BufferGeometry, Float32BufferAttribute } from 'three'
+import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
+import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
function createEmptyGeometry() {
- const geometry = new BufferGeometry()
- geometry.setAttribute('position', new Float32BufferAttribute([], 3))
- return geometry
+ return createPlaceholderGeometry()
}
const gridScale = 5
@@ -69,7 +67,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
useRegistry(node.id, 'ceiling', ref)
- const handlers = useNodeEvents(node, 'ceiling')
+ // Build the real geometry on mount instead of relying on a child item to
+ // mark us dirty (CeilingSystem only rebuilds dirty ceilings). Ceiling-hosted
+ // items are async GLB loads, so without this the ceiling holds its
+ // placeholder geometry until the first child finishes downloading — and a
+ // childless ceiling would never build at all. Mirrors WallRenderer /
+ // RoofRenderer.
+ useLayoutEffect(() => {
+ useScene.getState().markDirty(node.id)
+ }, [node.id])
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
@@ -123,7 +129,6 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
geometry={gridPlaceholderGeometry}
material={materials.topMaterial}
name="ceiling-grid"
- {...handlers}
scale={0}
visible={false}
/>
diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx
index ee4450868..77648015e 100644
--- a/packages/nodes/src/ceiling/tool.tsx
+++ b/packages/nodes/src/ceiling/tool.tsx
@@ -6,7 +6,6 @@ import {
type GridEvent,
type LevelNode,
resolveAlignment,
- useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import {
@@ -14,6 +13,7 @@ import {
EDITOR_LAYER,
markToolCancelConsumed,
triggerSFX,
+ useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx
index 21388a5ae..f9a314547 100644
--- a/packages/nodes/src/column/move-tool.tsx
+++ b/packages/nodes/src/column/move-tool.tsx
@@ -10,7 +10,6 @@ import {
movingFootprintAnchors,
resolveAlignment,
sceneRegistry,
- useAlignmentGuides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
@@ -23,6 +22,7 @@ import {
resolvePlanarCursorPosition,
stripPlacementMetadataFlags,
triggerSFX,
+ useAlignmentGuides,
useEditor,
useFreshPlacementVisibility,
} from '@pascal-app/editor'
diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx
index fdbc0cff7..6b221b4ab 100644
--- a/packages/nodes/src/column/tool.tsx
+++ b/packages/nodes/src/column/tool.tsx
@@ -7,12 +7,12 @@ import {
collectAlignmentAnchors,
emitter,
type GridEvent,
- useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import {
getFloorStackPreviewPosition,
triggerSFX,
+ useAlignmentGuides,
useEditor,
usePlacementPreview,
} from '@pascal-app/editor'
diff --git a/packages/nodes/src/door/floorplan.ts b/packages/nodes/src/door/floorplan.ts
index 61ac13e32..bae089ef9 100644
--- a/packages/nodes/src/door/floorplan.ts
+++ b/packages/nodes/src/door/floorplan.ts
@@ -13,12 +13,22 @@ import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dim
*
* 1. The door footprint rectangle in the wall cutout (themed
* accent stroke when selected).
- * 2. The door swing arc — a quarter-circle from the hinge to the
- * door's open position, modulated by `swingAngle`, `hingesSide`,
- * and `swingDirection`. Renders as a wedge of low-opacity fill so
- * the swept area reads at a glance.
+ * 2. The door swing arc — a fixed quarter-circle from the hinge to
+ * the door's fully-open (90°) position, oriented by `hingesSide`
+ * and `swingDirection`. The angle is intentionally constant so the
+ * plan symbol stays static regardless of the door's live open-close
+ * state. Renders as a wedge of low-opacity fill so the swept area
+ * reads at a glance.
* 3. The door leaf — a thick line from the hinge to the open
* position, terminating at the arc end.
+ *
+ * Double / french doors render two mirrored half-width leaves hinged at
+ * the opposite outer ends, each with its own dashed arc, meeting
+ * perpendicular at the centre — the standard double-door plan sign.
+ *
+ * Folding / bifold doors render a static zigzag accordion of panels
+ * across the opening (porting the 3D folding geometry's panel layout),
+ * with no swing arc.
* 4. Center line through the cutout (matches the legacy's
* `getOpeningCenterLine` segment for visual continuity).
*
@@ -65,7 +75,12 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
? 'outward'
: 'inward'
: baseSwingDirection
- const swingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0))
+ // The floor-plan door symbol is the standard architectural sign: the
+ // leaf drawn at a fixed 90° open with its quarter-circle swing arc.
+ // It deliberately ignores `node.swingAngle` / the live open-close
+ // animation — the plan view documents how the door is hung, not how
+ // far it currently happens to be open, so it must stay static.
+ const swingAngle = Math.PI / 2
// Footprint rectangle in the cutout.
const points: readonly FloorplanPoint[] = [
@@ -103,53 +118,49 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
},
]
- // Swing geometry. The hinge sits at one end of the door along the
- // wall direction; the strike sits at the opposite end. The leaf
- // rotates around the hinge by `swingAngle` toward the inward /
- // outward side of the wall.
- const hingeTangentSign = hingesSide === 'left' ? 1 : -1
+ // Swing geometry. A leaf is drawn as a wedge fill + dashed swing arc +
+ // solid leaf line. `drawSwingLeaf` emits one leaf given its hinge, the
+ // closed-leaf vector (hinge → strike, whose length is the swing
+ // radius) and a signed swing angle. Single doors draw one leaf; double
+ // / french doors draw two mirrored half-width leaves meeting in the
+ // middle.
const swingSign = swingDirection === 'inward' ? 1 : -1
- const hingeX = cx - dirX * halfWidth * hingeTangentSign
- const hingeZ = cz - dirZ * halfWidth * hingeTangentSign
- // Closed leaf vector points from hinge to strike (along the wall).
- const closedLeafX = dirX * width * hingeTangentSign
- const closedLeafZ = dirZ * width * hingeTangentSign
-
- if (swingAngle > 1e-3 && width > 1e-3) {
- // Rotate the closed leaf vector by `swingAngle * swingSign *
- // hingeTangentSign` around the hinge to get the open leaf tip.
- const angle = swingAngle * swingSign * hingeTangentSign
- const cos = Math.cos(angle)
- const sin = Math.sin(angle)
- const openLeafX = closedLeafX * cos - closedLeafZ * sin
- const openLeafZ = closedLeafX * sin + closedLeafZ * cos
- const tipX = hingeX + openLeafX
- const tipZ = hingeZ + openLeafZ
-
- // Closed leaf tip — where the leaf would land if fully closed.
- const closedTipX = hingeX + closedLeafX
- const closedTipZ = hingeZ + closedLeafZ
-
- // Swing arc — a path from closed tip to open tip via an arc
- // centered at the hinge. SVG's A command takes rx ry rotation
- // large-arc-flag sweep-flag x y. Sweep flag flips based on the
- // signed angle direction.
- const sweepFlag = angle >= 0 ? 1 : 0
- const arcPath = `M ${closedTipX} ${closedTipZ} A ${width} ${width} 0 0 ${sweepFlag} ${tipX} ${tipZ}`
-
- // Swept wedge fill (light, low opacity) — gives the door a
- // visible "this is the open zone" treatment.
+
+ const drawSwingLeaf = (
+ hX: number,
+ hZ: number,
+ closedX: number,
+ closedZ: number,
+ signedAngle: number,
+ ) => {
+ const radius = Math.sqrt(closedX * closedX + closedZ * closedZ)
+ if (radius < 1e-3) return
+
+ // Rotate the closed leaf vector around the hinge to the open tip.
+ const cos = Math.cos(signedAngle)
+ const sin = Math.sin(signedAngle)
+ const tipX = hX + (closedX * cos - closedZ * sin)
+ const tipZ = hZ + (closedX * sin + closedZ * cos)
+ const closedTipX = hX + closedX
+ const closedTipZ = hZ + closedZ
+
+ // Swing arc — from closed tip to open tip via an arc centered at the
+ // hinge. SVG `A`: rx ry rotation large-arc-flag sweep-flag x y. The
+ // sweep flag flips with the signed angle direction.
+ const sweepFlag = signedAngle >= 0 ? 1 : 0
+ const arcPath = `M ${closedTipX} ${closedTipZ} A ${radius} ${radius} 0 0 ${sweepFlag} ${tipX} ${tipZ}`
+
+ // Swept wedge fill (light, low opacity) — reads as the open zone.
children.push({
kind: 'path',
- d: `M ${hingeX} ${hingeZ} L ${closedTipX} ${closedTipZ} ${arcPath
- .replace(/^M [^A]+/, '')
- .trim()} Z`,
+ d: `M ${hX} ${hZ} L ${closedTipX} ${closedTipZ} ${arcPath.replace(/^M [^A]+/, '').trim()} Z`,
fill: accentColor,
fillOpacity: showSelectedChrome ? 0.08 : 0.05,
stroke: 'none',
})
- // The arc itself, stroked.
+ // The arc itself, dashed to match the standard architectural plan
+ // symbol, where the door's swing path is drawn as a broken arc.
children.push({
kind: 'path',
d: arcPath,
@@ -157,6 +168,9 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.6 : 1.1,
strokeOpacity: 0.85,
+ // Dash is in screen pixels because of `non-scaling-stroke` (same
+ // reason strokeWidth is a small pixel value, not metres).
+ strokeDasharray: '5 4',
vectorEffect: 'non-scaling-stroke',
strokeLinecap: 'round',
})
@@ -164,8 +178,8 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
// The door leaf — line from hinge to the open tip.
children.push({
kind: 'line',
- x1: hingeX,
- y1: hingeZ,
+ x1: hX,
+ y1: hZ,
x2: tipX,
y2: tipZ,
stroke: accentColor,
@@ -175,6 +189,497 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
})
}
+ const isDoubleLeaf = node.doorType === 'double' || node.doorType === 'french'
+ const isFolding = node.doorType === 'folding'
+ const isSliding = node.doorType === 'sliding'
+ const isPocket = node.doorType === 'pocket'
+ const isBarn = node.doorType === 'barn'
+ const isGarageSectional = node.doorType === 'garage-sectional'
+ const isGarageRollup = node.doorType === 'garage-rollup'
+ const isGarageTiltup = node.doorType === 'garage-tiltup'
+ // Swing doors get the dashed swing arc; garage and any other types
+ // fall through to just the opening footprint (the earlier behaviour).
+ const isSwingDoor = node.doorType === 'hinged' || isDoubleLeaf
+ // A frameless wall opening — drawn as a bare gap, no leaf / arc / panel
+ // (mirrors the 3D system, which renders only the cutout for openings).
+ const isOpening = node.openingKind === 'opening'
+
+ if (isOpening) {
+ // Open doorway — a frameless gap in the wall. No leaf, arc, or panel;
+ // the cleared footprint above is the whole symbol.
+ } else if (isFolding && width > 1e-3) {
+ // Folding / bifold door: a static accordion of panels drawn as a
+ // zigzag that folds toward the hinge side, occupying ~70% of the
+ // opening (a real folding door stacks to one side, so it never
+ // spans the full width). Mirrors the 3D folding geometry's
+ // alternating-fold panels (`panelCount = leafCount === 2 ? 2 : 4`).
+ // The fold span / depth are fixed so the plan symbol stays static
+ // regardless of the live open state.
+ const FOLD_SPAN_RATIO = 0.8
+ const panelCount = node.leafCount === 2 ? 2 : 4
+ const panelLen = (width * FOLD_SPAN_RATIO) / panelCount
+ const peakDepth = panelLen * 0.7
+ // Anchor the accordion's base line on the room-facing wall face (the
+ // edge / corner of the opening box) rather than the wall centreline,
+ // so the panels start from the box corner instead of its middle.
+ const baseOffX = perpX * halfDepth * swingSign
+ const baseOffZ = perpZ * halfDepth * swingSign
+ // Start at the hinge edge and step toward the opposite jamb.
+ const hingeTangentSign = hingesSide === 'left' ? 1 : -1
+ const startX = cx - dirX * halfWidth * hingeTangentSign + baseOffX
+ const startZ = cz - dirZ * halfWidth * hingeTangentSign + baseOffZ
+ const stepX = dirX * panelLen * hingeTangentSign
+ const stepZ = dirZ * panelLen * hingeTangentSign
+ const peakX = perpX * peakDepth * swingSign
+ const peakZ = perpZ * peakDepth * swingSign
+ let d = ''
+ for (let i = 0; i <= panelCount; i++) {
+ const alongX = startX + stepX * i
+ const alongZ = startZ + stepZ * i
+ const isPeak = i % 2 === 1
+ const px = alongX + (isPeak ? peakX : 0)
+ const pz = alongZ + (isPeak ? peakZ : 0)
+ d += `${i === 0 ? 'M' : 'L'} ${px} ${pz} `
+ }
+ children.push({
+ kind: 'path',
+ d: d.trim(),
+ fill: 'none',
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 2.4 : 1.7,
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ vectorEffect: 'non-scaling-stroke',
+ })
+ } else if (isSliding && width > 1e-3) {
+ // Sliding (bypass) door: two panels on parallel tracks that slide
+ // past each other. Each spans a bit over half the opening and they
+ // overlap in the centre, drawn at slightly different depths (one
+ // toward each wall face). A slide arrow shows the direction. Static
+ // regardless of the live open state.
+ const slideSign = node.slideDirection === 'right' ? 1 : -1
+ const panelHalfThick = Math.min(depth * 0.22, 0.03)
+ const panelHalfLen = width * 0.275
+ const panelRect = (centerAlong: number, perpSign: number): [number, number][] => {
+ const rcx = cx + dirX * centerAlong + perpX * panelHalfThick * perpSign
+ const rcz = cz + dirZ * centerAlong + perpZ * panelHalfThick * perpSign
+ const aX = dirX * panelHalfLen
+ const aZ = dirZ * panelHalfLen
+ const tX = perpX * panelHalfThick
+ const tZ = perpZ * panelHalfThick
+ return [
+ [rcx - aX + tX, rcz - aZ + tZ],
+ [rcx + aX + tX, rcz + aZ + tZ],
+ [rcx + aX - tX, rcz + aZ - tZ],
+ [rcx - aX - tX, rcz - aZ - tZ],
+ ]
+ }
+ const pushPanel = (points: [number, number][]) =>
+ children.push({
+ kind: 'polygon',
+ points,
+ fill: fillColor,
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 2 : 1.4,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ // Left panel toward one face, right panel toward the other; they
+ // overlap across the centre.
+ pushPanel(panelRect(-halfWidth + panelHalfLen, 1))
+ pushPanel(panelRect(halfWidth - panelHalfLen, -1))
+ // Slide-direction arrow, centred and pushed clear of the wall face
+ // so it doesn't overlap the wall or panels.
+ const arrowPerp = -(halfDepth + Math.max(panelHalfThick * 4, halfWidth * 0.22))
+ const arX = cx + perpX * arrowPerp
+ const arZ = cz + perpZ * arrowPerp
+ const tipX = arX + dirX * halfWidth * 0.5 * slideSign
+ const tipZ = arZ + dirZ * halfWidth * 0.5 * slideSign
+ const tailX = arX - dirX * halfWidth * 0.5 * slideSign
+ const tailZ = arZ - dirZ * halfWidth * 0.5 * slideSign
+ const headLen = halfWidth * 0.18
+ const headSpread = headLen * 0.6
+ const backX = tipX - dirX * headLen * slideSign
+ const backZ = tipZ - dirZ * headLen * slideSign
+ children.push({
+ kind: 'path',
+ d:
+ `M ${tailX} ${tailZ} L ${tipX} ${tipZ} ` +
+ `M ${backX + perpX * headSpread} ${backZ + perpZ * headSpread} L ${tipX} ${tipZ} ` +
+ `L ${backX - perpX * headSpread} ${backZ - perpZ * headSpread}`,
+ fill: 'none',
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 1.4 : 1,
+ strokeOpacity: 0.85,
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ vectorEffect: 'non-scaling-stroke',
+ })
+ } else if (isPocket && width > 1e-3) {
+ // Pocket door: the leaf slides into a cavity inside the wall. Shown
+ // open — the leaf is tucked into the wall on the `slideDirection`
+ // side, and the carved pocket slot is sized to match the leaf (no
+ // empty cavity sticking out past it). The opening itself reads as a
+ // clear doorway. Static regardless of the live open state.
+ const slideSign = node.slideDirection === 'right' ? 1 : -1
+ const leafHalfThick = Math.min(depth * 0.2, 0.025)
+ const rectPoints = (
+ centerAlong: number,
+ halfLen: number,
+ halfThick: number,
+ ): [number, number][] => {
+ const rcx = cx + dirX * centerAlong
+ const rcz = cz + dirZ * centerAlong
+ const aX = dirX * halfLen
+ const aZ = dirZ * halfLen
+ const tX = perpX * halfThick
+ const tZ = perpZ * halfThick
+ return [
+ [rcx - aX + tX, rcz - aZ + tZ],
+ [rcx + aX + tX, rcz + aZ + tZ],
+ [rcx + aX - tX, rcz + aZ - tZ],
+ [rcx - aX - tX, rcz - aZ - tZ],
+ ]
+ }
+ // Show the door ~60% closed: the leaf covers 60% of the opening and
+ // has slid 40% of its width into the pocket. The pocket side of the
+ // wall stays solid (no carve) so the in-wall part of the leaf reads
+ // as an outline over it, matching the standard plan symbol.
+ const CLOSE_FRACTION = 0.6
+ const openOffset = (1 - CLOSE_FRACTION) * width
+ const leafCenter = slideSign * openOffset
+ // The leaf is a thin white rectangle with an outline — white-filled
+ // along its whole length, so the part sliding into the wall carves
+ // its shape out in white rather than showing the solid wall behind.
+ children.push({
+ kind: 'polygon',
+ points: rectPoints(leafCenter, halfWidth, leafHalfThick),
+ fill: '#ffffff',
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 2 : 1.4,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ } else if (isBarn && width > 1e-3) {
+ // Barn / surface-sliding door: the leaf rides a surface-mounted
+ // track in front of the wall. Shown as a solid panel parked over the
+ // wall on the slide side, a dashed ghost of its closed position over
+ // the opening, and a slide-direction arrow. `slideDirection` picks
+ // the side. Static regardless of the live open state.
+ const slideSign = node.slideDirection === 'right' ? 1 : -1
+ const panelHalfThick = Math.min(depth * 0.3, 0.04)
+ // Sit the panels clearly in front of the wall face with a gap, so
+ // they read as surface-mounted rather than embedded in the wall.
+ const gap = Math.max(panelHalfThick * 1.6, halfDepth * 0.6)
+ const faceOffset = halfDepth + gap + panelHalfThick
+ const frontRect = (centerAlong: number, halfLen: number): [number, number][] => {
+ const fcx = cx + perpX * faceOffset + dirX * centerAlong
+ const fcz = cz + perpZ * faceOffset + dirZ * centerAlong
+ const aX = dirX * halfLen
+ const aZ = dirZ * halfLen
+ const tX = perpX * panelHalfThick
+ const tZ = perpZ * panelHalfThick
+ return [
+ [fcx - aX + tX, fcz - aZ + tZ],
+ [fcx + aX + tX, fcz + aZ + tZ],
+ [fcx + aX - tX, fcz + aZ - tZ],
+ [fcx - aX - tX, fcz - aZ - tZ],
+ ]
+ }
+ // Dashed ghost of the closed position across the opening.
+ children.push({
+ kind: 'polygon',
+ points: frontRect(0, halfWidth),
+ fill: 'none',
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 1.4 : 1,
+ strokeDasharray: '5 4',
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ // Solid panel parked over the wall on the slide side.
+ children.push({
+ kind: 'polygon',
+ points: frontRect(slideSign * width, halfWidth),
+ fill: accentColor,
+ fillOpacity: showSelectedChrome ? 0.25 : 0.18,
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 2 : 1.4,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ // Slide-direction arrow, pushed out beyond the panels so it clears
+ // them instead of sitting on top.
+ const arrowOffset = faceOffset + panelHalfThick + gap
+ const arX = cx + perpX * arrowOffset
+ const arZ = cz + perpZ * arrowOffset
+ const tipX = arX + dirX * halfWidth * 0.8 * slideSign
+ const tipZ = arZ + dirZ * halfWidth * 0.8 * slideSign
+ const tailX = arX - dirX * halfWidth * 0.8 * slideSign
+ const tailZ = arZ - dirZ * halfWidth * 0.8 * slideSign
+ const headLen = halfWidth * 0.18
+ const headSpread = headLen * 0.6
+ const backX = tipX - dirX * headLen * slideSign
+ const backZ = tipZ - dirZ * headLen * slideSign
+ children.push({
+ kind: 'path',
+ d:
+ `M ${tailX} ${tailZ} L ${tipX} ${tipZ} ` +
+ `M ${backX + perpX * headSpread} ${backZ + perpZ * headSpread} L ${tipX} ${tipZ} ` +
+ `L ${backX - perpX * headSpread} ${backZ - perpZ * headSpread}`,
+ fill: 'none',
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 1.4 : 1,
+ strokeOpacity: 0.85,
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ vectorEffect: 'non-scaling-stroke',
+ })
+ } else if (isGarageSectional && width > 1e-3) {
+ // Garage sectional door: an overhead door that rolls up on side
+ // tracks. Drawn as the closed leaf across the opening (just inside
+ // the interior face), two tracks running into the garage, and a
+ // dashed ghost of the door parked at the inner end of the tracks.
+ // `swingDirection` picks the interior side. Static.
+ // Mechanism (tracks / coil) sits on the interior side — matching the
+ // 3D garage builders, which place it on the door-local -z side.
+ const interiorSign = -swingSign
+ const panelHalfThick = Math.min(depth * 0.22, 0.03)
+ const trackLen = Math.max(width * 0.55, 0.6)
+ const aX = dirX * halfWidth
+ const aZ = dirZ * halfWidth
+ const tX = perpX * panelHalfThick
+ const tZ = perpZ * panelHalfThick
+ const leafRect = (perpDist: number): [number, number][] => {
+ const rcx = cx + perpX * perpDist
+ const rcz = cz + perpZ * perpDist
+ return [
+ [rcx - aX + tX, rcz - aZ + tZ],
+ [rcx + aX + tX, rcz + aZ + tZ],
+ [rcx + aX - tX, rcz + aZ - tZ],
+ [rcx - aX - tX, rcz - aZ - tZ],
+ ]
+ }
+ const faceDist = interiorSign * halfDepth
+ const innerDist = interiorSign * (halfDepth + trackLen)
+ // Two side tracks running into the garage interior.
+ for (const edgeSign of [-1, 1]) {
+ const ex = cx + dirX * halfWidth * edgeSign
+ const ez = cz + dirZ * halfWidth * edgeSign
+ children.push({
+ kind: 'line',
+ x1: ex + perpX * faceDist,
+ y1: ez + perpZ * faceDist,
+ x2: ex + perpX * innerDist,
+ y2: ez + perpZ * innerDist,
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 1.4 : 1,
+ strokeOpacity: 0.85,
+ strokeDasharray: '5 4',
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinecap: 'round',
+ })
+ }
+ // Dashed ghost of the door parked at the inner end of the tracks.
+ children.push({
+ kind: 'polygon',
+ points: leafRect(innerDist - interiorSign * panelHalfThick),
+ fill: 'none',
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 1.4 : 1,
+ strokeDasharray: '5 4',
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ // Closed leaf, just inside the interior wall face.
+ children.push({
+ kind: 'polygon',
+ points: leafRect(interiorSign * (halfDepth + panelHalfThick)),
+ fill: fillColor,
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 2 : 1.4,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ } else if (isGarageRollup && width > 1e-3) {
+ // Roll-up garage door: the curtain coils into a barrel just inside
+ // the opening (rather than running back on tracks like a sectional).
+ // Drawn as the closed leaf across the opening, the coil barrel — a
+ // capsule parallel to the wall — and a small coil hint at its centre.
+ // `swingDirection` picks the interior side. Static.
+ // Mechanism (tracks / coil) sits on the interior side — matching the
+ // 3D garage builders, which place it on the door-local -z side.
+ const interiorSign = -swingSign
+ const panelHalfThick = Math.min(depth * 0.22, 0.03)
+ const aX = dirX * halfWidth
+ const aZ = dirZ * halfWidth
+ const tX = perpX * panelHalfThick
+ const tZ = perpZ * panelHalfThick
+ // Closed leaf, just inside the interior wall face.
+ const leafPerp = interiorSign * (halfDepth + panelHalfThick)
+ const lcx = cx + perpX * leafPerp
+ const lcz = cz + perpZ * leafPerp
+ children.push({
+ kind: 'polygon',
+ points: [
+ [lcx - aX + tX, lcz - aZ + tZ],
+ [lcx + aX + tX, lcz + aZ + tZ],
+ [lcx + aX - tX, lcz + aZ - tZ],
+ [lcx - aX - tX, lcz - aZ - tZ],
+ ],
+ fill: fillColor,
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 2 : 1.4,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ // Coil barrel — a capsule (stadium) parallel to the wall, just inside
+ // the leaf. Built as a polygon so it's robust to wall orientation.
+ const drumRadius = Math.min(Math.max(width * 0.12, 0.08), halfWidth * 0.5, 0.16)
+ const drumPerp = interiorSign * (halfDepth + 2 * panelHalfThick + drumRadius)
+ const dcx = cx + perpX * drumPerp
+ const dcz = cz + perpZ * drumPerp
+ const capL = Math.max(halfWidth - drumRadius, 0)
+ const SAMPLES = 8
+ const capsule: [number, number][] = []
+ const c2x = dcx + dirX * capL
+ const c2z = dcz + dirZ * capL
+ for (let i = 0; i <= SAMPLES; i++) {
+ const th = Math.PI / 2 - (Math.PI * i) / SAMPLES
+ capsule.push([
+ c2x + drumRadius * (Math.cos(th) * dirX + Math.sin(th) * perpX),
+ c2z + drumRadius * (Math.cos(th) * dirZ + Math.sin(th) * perpZ),
+ ])
+ }
+ const c1x = dcx - dirX * capL
+ const c1z = dcz - dirZ * capL
+ for (let i = 0; i <= SAMPLES; i++) {
+ const ph = -Math.PI / 2 - (Math.PI * i) / SAMPLES
+ capsule.push([
+ c1x + drumRadius * (Math.cos(ph) * dirX + Math.sin(ph) * perpX),
+ c1z + drumRadius * (Math.cos(ph) * dirZ + Math.sin(ph) * perpZ),
+ ])
+ }
+ children.push({
+ kind: 'polygon',
+ points: capsule,
+ fill: fillColor,
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 1.6 : 1.1,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ // Coil hint — a small circle at the barrel centre.
+ const innerR = drumRadius * 0.45
+ const coil: [number, number][] = []
+ for (let i = 0; i <= 12; i++) {
+ const a = (Math.PI * 2 * i) / 12
+ coil.push([
+ dcx + innerR * (Math.cos(a) * dirX + Math.sin(a) * perpX),
+ dcz + innerR * (Math.cos(a) * dirZ + Math.sin(a) * perpZ),
+ ])
+ }
+ children.push({
+ kind: 'polygon',
+ points: coil,
+ fill: 'none',
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 1.4 : 1,
+ strokeOpacity: 0.85,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ } else if (isGarageTiltup && width > 1e-3) {
+ // Tilt-up (up-and-over) garage door: one rigid panel that pivots at
+ // the top and swings up to park overhead inside the garage. Drawn as
+ // the closed leaf across the opening, a dashed panel parked into the
+ // interior, and a dashed curved swing path between them.
+ // `swingDirection` is ignored; like the other garage builders the
+ // mechanism is on the door-local -z (interior) side. Static.
+ const interiorSign = -swingSign
+ const panelHalfThick = Math.min(depth * 0.22, 0.03)
+ const projDepth = Math.max(width * 0.5, 0.7)
+ const aX = dirX * halfWidth
+ const aZ = dirZ * halfWidth
+ const tX = perpX * panelHalfThick
+ const tZ = perpZ * panelHalfThick
+ const leafRect = (perpDist: number): [number, number][] => {
+ const rcx = cx + perpX * perpDist
+ const rcz = cz + perpZ * perpDist
+ return [
+ [rcx - aX + tX, rcz - aZ + tZ],
+ [rcx + aX + tX, rcz + aZ + tZ],
+ [rcx + aX - tX, rcz + aZ - tZ],
+ [rcx - aX - tX, rcz - aZ - tZ],
+ ]
+ }
+ const closedPerp = interiorSign * (halfDepth + panelHalfThick)
+ const parkedPerp = interiorSign * (halfDepth + projDepth)
+ // Dashed parked panel, projected overhead into the interior.
+ children.push({
+ kind: 'polygon',
+ points: leafRect(parkedPerp),
+ fill: 'none',
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 1.4 : 1,
+ strokeDasharray: '5 4',
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ // Dashed curved swing path from the closed leaf to the parked panel.
+ const startX = cx + perpX * closedPerp
+ const startZ = cz + perpZ * closedPerp
+ const endX = cx + perpX * parkedPerp
+ const endZ = cz + perpZ * parkedPerp
+ const midPerp = interiorSign * (halfDepth + projDepth * 0.5)
+ const ctrlX = cx + perpX * midPerp + dirX * projDepth * 0.5
+ const ctrlZ = cz + perpZ * midPerp + dirZ * projDepth * 0.5
+ children.push({
+ kind: 'path',
+ d: `M ${startX} ${startZ} Q ${ctrlX} ${ctrlZ} ${endX} ${endZ}`,
+ fill: 'none',
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 1.4 : 1,
+ strokeOpacity: 0.85,
+ strokeDasharray: '5 4',
+ strokeLinecap: 'round',
+ vectorEffect: 'non-scaling-stroke',
+ })
+ // Closed leaf, just inside the interior wall face.
+ children.push({
+ kind: 'polygon',
+ points: leafRect(closedPerp),
+ fill: fillColor,
+ stroke: accentColor,
+ strokeWidth: showSelectedChrome ? 2 : 1.4,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinejoin: 'round',
+ })
+ } else if (isSwingDoor && swingAngle > 1e-3 && width > 1e-3) {
+ if (isDoubleLeaf) {
+ // Two half-width leaves hinged at the opposite outer ends, each
+ // swinging toward the centre. `hingesSide` is irrelevant for a
+ // symmetric double door; only `swingDirection` chooses the side.
+ const halfLeafX = dirX * halfWidth
+ const halfLeafZ = dirZ * halfWidth
+ // Leaf at the start edge: closed leaf points toward the centre.
+ drawSwingLeaf(cx - halfLeafX, cz - halfLeafZ, halfLeafX, halfLeafZ, swingAngle * swingSign)
+ // Leaf at the end edge: closed leaf points the other way, swung
+ // with the opposite sign so both meet perpendicular at the centre.
+ drawSwingLeaf(cx + halfLeafX, cz + halfLeafZ, -halfLeafX, -halfLeafZ, -swingAngle * swingSign)
+ } else {
+ // Single leaf hinged at one end, strike at the opposite end.
+ const hingeTangentSign = hingesSide === 'left' ? 1 : -1
+ drawSwingLeaf(
+ cx - dirX * halfWidth * hingeTangentSign,
+ cz - dirZ * halfWidth * hingeTangentSign,
+ dirX * width * hingeTangentSign,
+ dirZ * width * hingeTangentSign,
+ swingAngle * swingSign * hingeTangentSign,
+ )
+ }
+ }
+
// Move handle — orange dot at the door center. Only visible when
// selected. Pointer-down on this triggers `setMovingNode(door)`
// → `FloorplanRegistryMoveOverlay` → `def.floorplanMoveTarget`.
diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx
index 44bf81cfc..a86472887 100644
--- a/packages/nodes/src/door/move-tool.tsx
+++ b/packages/nodes/src/door/move-tool.tsx
@@ -6,7 +6,6 @@ import {
isCurvedWall,
sceneRegistry,
spatialGridManager,
- useAlignmentGuides,
useLiveTransforms,
useScene,
type WallEvent,
@@ -19,6 +18,7 @@ import {
isValidWallSideFace,
stripPlacementMetadataFlags,
triggerSFX,
+ useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx
index e579246e5..eaec7683b 100644
--- a/packages/nodes/src/door/tool.tsx
+++ b/packages/nodes/src/door/tool.tsx
@@ -6,7 +6,6 @@ import {
isCurvedWall,
sceneRegistry,
spatialGridManager,
- useAlignmentGuides,
useScene,
type WallEvent,
} from '@pascal-app/core'
@@ -17,6 +16,7 @@ import {
getSideFromNormal,
isValidWallSideFace,
triggerSFX,
+ useAlignmentGuides,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts
index 78722bfa6..d1a6d5161 100644
--- a/packages/nodes/src/fence/actions/move-endpoint.ts
+++ b/packages/nodes/src/fence/actions/move-endpoint.ts
@@ -6,7 +6,6 @@ import {
type DragAction,
type FenceNode,
resolveAlignment,
- useAlignmentGuides,
useScene,
type WallNode,
} from '@pascal-app/core'
@@ -14,6 +13,7 @@ import {
type FencePlanPoint,
isSegmentLongEnough,
snapFenceDraftPoint,
+ useAlignmentGuides,
WALL_FINE_GRID_STEP,
} from '@pascal-app/editor'
diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts
index 7ca4e1473..0fca3ca77 100644
--- a/packages/nodes/src/fence/floorplan-affordances.ts
+++ b/packages/nodes/src/fence/floorplan-affordances.ts
@@ -7,7 +7,6 @@ import {
getMaxWallCurveOffset,
getWallChordFrame,
normalizeWallCurveOffset,
- useAlignmentGuides,
useLiveNodeOverrides,
useScene,
type WallNode,
@@ -19,6 +18,7 @@ import {
isSegmentLongEnough,
snapFenceDraftPoint,
snapScalarToGrid,
+ useAlignmentGuides,
WALL_FINE_GRID_STEP,
} from '@pascal-app/editor'
diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx
index c56f4c2ab..c5bc0a6ec 100644
--- a/packages/nodes/src/fence/move-endpoint-tool.tsx
+++ b/packages/nodes/src/fence/move-endpoint-tool.tsx
@@ -1,12 +1,6 @@
'use client'
-import {
- type FenceNode,
- getWallCurveLength,
- useAlignmentGuides,
- useScene,
- type WallNode,
-} from '@pascal-app/core'
+import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core'
import {
CursorSphere,
type FencePlanPoint,
@@ -16,6 +10,7 @@ import {
MeasurementPill,
type MovingFenceEndpoint,
triggerSFX,
+ useAlignmentGuides,
useDragAction,
useEditor,
} from '@pascal-app/editor'
diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx
index f7074b79d..4767aa92f 100644
--- a/packages/nodes/src/fence/tool.tsx
+++ b/packages/nodes/src/fence/tool.tsx
@@ -10,7 +10,6 @@ import {
type LevelNode,
type Point2D,
resolveAlignment,
- useAlignmentGuides,
useScene,
type WallMiterData,
type WallNode,
@@ -28,6 +27,7 @@ import {
type SegmentAngleReference,
snapFenceDraftPoint,
triggerSFX,
+ useAlignmentGuides,
useEditor,
WALL_FINE_GRID_STEP,
} from '@pascal-app/editor'
diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts
index 08b75bf3f..e7de994c5 100644
--- a/packages/nodes/src/item/definition.ts
+++ b/packages/nodes/src/item/definition.ts
@@ -1,4 +1,5 @@
import {
+ type AnyNode,
getScaledDimensions,
type HandleDescriptor,
type ItemNode as ItemNodeType,
@@ -221,6 +222,39 @@ export const itemDefinition: NodeDefinition = {
},
applies: (node) => !(node as ItemNodeType).asset.attachTo,
},
+ // Recessed ceiling fixtures cut a hole in their host ceiling. The viewer's
+ // CeilingSystem queries this capability on each child of a ceiling so it
+ // never needs to branch on `node.type`.
+ ceilingCut: {
+ buildCeilingHole(rawNode: AnyNode): Array<[number, number]> | null {
+ const node = rawNode as ItemNodeType
+ if (!node.asset.recessed || node.asset.attachTo !== 'ceiling') return null
+
+ // Inset slightly so the fixture's trim (widest part, sitting in the
+ // ceiling plane) overlaps the solid ceiling around the opening and hides
+ // the cut edge. Same constant the old ceiling-system used.
+ const INSET = 0.82
+ const [width, , depth] = getScaledDimensions(node)
+ const halfW = (width / 2) * INSET
+ const halfD = (depth / 2) * INSET
+ const cx = node.position[0]
+ const cz = node.position[2]
+ const yaw = node.rotation?.[1] ?? 0
+ const cos = Math.cos(yaw)
+ const sin = Math.sin(yaw)
+
+ // Rotate the (inset) footprint corners about Y and translate to the
+ // item's plan position. Y-rotation of (dx, dz): (dx·cos + dz·sin, −dx·sin + dz·cos).
+ return (
+ [
+ [-halfW, -halfD],
+ [halfW, -halfD],
+ [halfW, halfD],
+ [-halfW, halfD],
+ ] as Array<[number, number]>
+ ).map(([dx, dz]) => [cx + dx * cos + dz * sin, cz - dx * sin + dz * cos])
+ },
+ },
},
parametrics: itemParametrics,
diff --git a/packages/nodes/src/item/panel.tsx b/packages/nodes/src/item/panel.tsx
index 3cd4e317a..d484410f8 100644
--- a/packages/nodes/src/item/panel.tsx
+++ b/packages/nodes/src/item/panel.tsx
@@ -54,7 +54,7 @@ export default function ItemPanel() {
// frame regenerates its cutout geometry around the moved item.
if (n.asset.attachTo === 'wall' && n.parentId) {
requestAnimationFrame(() => {
- useScene.getState().dirtyNodes.add(n.parentId as AnyNode['id'])
+ useScene.getState().markDirty(n.parentId as AnyNode['id'])
})
}
},
diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx
index 37abb3607..5814a986f 100644
--- a/packages/nodes/src/item/renderer.tsx
+++ b/packages/nodes/src/item/renderer.tsx
@@ -156,7 +156,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
useEffect(() => {
if (!node.parentId) return
- useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
+ useScene.getState().markDirty(node.parentId as AnyNodeId)
}, [node.parentId])
useEffect(() => {
diff --git a/packages/nodes/src/roof-segment/renderer.tsx b/packages/nodes/src/roof-segment/renderer.tsx
index 69d58acd6..8c1b50492 100644
--- a/packages/nodes/src/roof-segment/renderer.tsx
+++ b/packages/nodes/src/roof-segment/renderer.tsx
@@ -20,6 +20,7 @@ import {
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { getRoofDebugMaterials, getRoofMaterials } from '../roof/roof-materials'
+import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const ref = useRef(null!)
@@ -36,15 +37,8 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const parentNode = node.parentId
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
: undefined
- const placeholderGeometry = useMemo(() => {
- const geometry = new THREE.BufferGeometry()
- geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
- geometry.addGroup(0, 0, 0)
- geometry.addGroup(0, 0, 1)
- geometry.addGroup(0, 0, 2)
- geometry.addGroup(0, 0, 3)
- return geometry
- }, [])
+ // 4 groups map 1:1 to the roof's 4-material array (see getRoofMaterialArray).
+ const placeholderGeometry = useMemo(() => createPlaceholderGeometry(4), [])
// Segment material precedence, per-role:
// 1. Segment's role-specific override (topMaterial, edgeMaterial, wallMaterial).
diff --git a/packages/nodes/src/roof/renderer.tsx b/packages/nodes/src/roof/renderer.tsx
index dd68f3597..45a288711 100644
--- a/packages/nodes/src/roof/renderer.tsx
+++ b/packages/nodes/src/roof/renderer.tsx
@@ -11,8 +11,9 @@ import {
} from '@pascal-app/core'
import { getRoofMaterialArray, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
-import * as THREE from 'three'
+import type * as THREE from 'three'
import { useShallow } from 'zustand/react/shallow'
+import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials'
export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => {
@@ -80,15 +81,8 @@ export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => {
}),
)
- const placeholderGeometry = useMemo(() => {
- const geometry = new THREE.BufferGeometry()
- geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
- geometry.addGroup(0, 0, 0)
- geometry.addGroup(0, 0, 1)
- geometry.addGroup(0, 0, 2)
- geometry.addGroup(0, 0, 3)
- return geometry
- }, [])
+ // 4 groups map 1:1 to the roof's 4-material array (see getRoofMaterialArray).
+ const placeholderGeometry = useMemo(() => createPlaceholderGeometry(4), [])
const customMaterial = useMemo(
() => getRoofMaterialArray(node, shading, textures, colorPreset, sceneTheme),
diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx
index 5826bb8d4..ac31d9189 100644
--- a/packages/nodes/src/shared/move-roof-tool.tsx
+++ b/packages/nodes/src/shared/move-roof-tool.tsx
@@ -13,7 +13,6 @@ import {
type StairNode,
type StairSegmentNode,
sceneRegistry,
- useAlignmentGuides,
useLiveTransforms,
useScene,
type WallNode,
@@ -26,6 +25,7 @@ import {
snapFenceDraftPoint,
stripPlacementMetadataFlags,
triggerSFX,
+ useAlignmentGuides,
useEditor,
useFreshPlacementVisibility,
type WallPlanPoint,
diff --git a/packages/nodes/src/shared/placeholder-geometry.ts b/packages/nodes/src/shared/placeholder-geometry.ts
new file mode 100644
index 000000000..287a45af7
--- /dev/null
+++ b/packages/nodes/src/shared/placeholder-geometry.ts
@@ -0,0 +1,27 @@
+import { BufferGeometry, Float32BufferAttribute } from 'three'
+
+/**
+ * Placeholder geometry for a mesh whose real geometry is filled in later by a
+ * system (wall / roof / roof-segment / ceiling / stair-segment). These meshes
+ * are mounted *visible*, so the WebGPU renderer draws them on the first
+ * frame(s) before the owning system runs — and the system passes are
+ * rate-limited, so several meshes can still hold the placeholder across
+ * multiple frames.
+ *
+ * It carries three zero-vertices — a single degenerate, zero-area (invisible)
+ * triangle — rather than an empty `position` attribute. An empty attribute
+ * (count 0) makes three.js create no GPU buffer for it, so vertex buffer slot 0
+ * is never bound and WebGPU rejects the draw with "Vertex buffer slot 0 … was
+ * not set", which poisons the whole command encoder (cascading into "Invalid
+ * CommandBuffer" on every queue submit). Three real vertices give it a bound
+ * buffer; the `groupCount` count-0 groups keep nothing drawn while matching the
+ * mesh's material-array length so raycasts / BVH never index past the materials.
+ */
+export function createPlaceholderGeometry(groupCount = 0): BufferGeometry {
+ const geometry = new BufferGeometry()
+ geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3))
+ for (let group = 0; group < groupCount; group++) {
+ geometry.addGroup(0, 0, group)
+ }
+ return geometry
+}
diff --git a/packages/nodes/src/shared/polygon-centroid-move.ts b/packages/nodes/src/shared/polygon-centroid-move.ts
index 516b076ed..470a02805 100644
--- a/packages/nodes/src/shared/polygon-centroid-move.ts
+++ b/packages/nodes/src/shared/polygon-centroid-move.ts
@@ -6,11 +6,10 @@ import {
polygonAnchors,
resolveAlignment,
sceneRegistry,
- useAlignmentGuides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
-import { getSegmentGridStep, type WallPlanPoint } from '@pascal-app/editor'
+import { getSegmentGridStep, useAlignmentGuides, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
import { createFloorplanCursorResolver } from './floorplan-cursor'
diff --git a/packages/nodes/src/shared/wall-opening-alignment.ts b/packages/nodes/src/shared/wall-opening-alignment.ts
index bda92e227..43af9e469 100644
--- a/packages/nodes/src/shared/wall-opening-alignment.ts
+++ b/packages/nodes/src/shared/wall-opening-alignment.ts
@@ -1,10 +1,5 @@
-import {
- type AlignmentAnchor,
- resolveAlignment,
- useAlignmentGuides,
- type WallNode,
-} from '@pascal-app/core'
-import { snapToHalf } from '@pascal-app/editor'
+import { type AlignmentAnchor, resolveAlignment, type WallNode } from '@pascal-app/core'
+import { snapToHalf, useAlignmentGuides } from '@pascal-app/editor'
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
export const WALL_OPENING_ALIGNMENT_THRESHOLD_M = 0.08
diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx
index 0db523418..4dbc866bb 100644
--- a/packages/nodes/src/shelf/tool.tsx
+++ b/packages/nodes/src/shelf/tool.tsx
@@ -5,10 +5,14 @@ import {
emitter,
type GridEvent,
ShelfNode,
- useAlignmentGuides,
useScene,
} from '@pascal-app/core'
-import { getFloorStackPreviewPosition, triggerSFX, useEditor } from '@pascal-app/editor'
+import {
+ getFloorStackPreviewPosition,
+ triggerSFX,
+ useAlignmentGuides,
+ useEditor,
+} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Group } from 'three'
diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx
index 4bbb9bec4..54e29d22f 100644
--- a/packages/nodes/src/slab/move-tool.tsx
+++ b/packages/nodes/src/slab/move-tool.tsx
@@ -11,7 +11,6 @@ import {
resolveAlignment,
type SlabNode,
sceneRegistry,
- useAlignmentGuides,
useLiveTransforms,
useScene,
type WallNode,
@@ -21,6 +20,7 @@ import {
markToolCancelConsumed,
snapFenceDraftPoint,
triggerSFX,
+ useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx
index e751ea25e..1121c6d4f 100644
--- a/packages/nodes/src/slab/tool.tsx
+++ b/packages/nodes/src/slab/tool.tsx
@@ -6,7 +6,6 @@ import {
type GridEvent,
type LevelNode,
resolveAlignment,
- useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import {
@@ -14,6 +13,7 @@ import {
EDITOR_LAYER,
markToolCancelConsumed,
triggerSFX,
+ useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
diff --git a/packages/nodes/src/stair-segment/renderer.tsx b/packages/nodes/src/stair-segment/renderer.tsx
index 10c343b0f..5ad30897d 100644
--- a/packages/nodes/src/stair-segment/renderer.tsx
+++ b/packages/nodes/src/stair-segment/renderer.tsx
@@ -9,7 +9,8 @@ import {
} from '@pascal-app/core'
import { getStraightStairSegmentBodyMaterials, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
-import * as THREE from 'three'
+import type * as THREE from 'three'
+import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
const ref = useRef(null!)
@@ -55,13 +56,8 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
parentNode,
])
- const placeholderGeometry = useMemo(() => {
- const geometry = new THREE.BufferGeometry()
- geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
- geometry.addGroup(0, 0, 0)
- geometry.addGroup(0, 0, 1)
- return geometry
- }, [])
+ // 2 groups map 1:1 to the stair segment's 2-material array (body + tread).
+ const placeholderGeometry = useMemo(() => createPlaceholderGeometry(2), [])
useEffect(() => {
return () => {
diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx
index 903ad3ed5..ec928e186 100644
--- a/packages/nodes/src/stair/renderer.tsx
+++ b/packages/nodes/src/stair/renderer.tsx
@@ -22,6 +22,7 @@ import {
} from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
+import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
type SegmentTransform = {
position: [number, number, number]
@@ -106,13 +107,8 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
[node, shading, textures, colorPreset],
)
- const straightPlaceholderGeometry = useMemo(() => {
- const geometry = new THREE.BufferGeometry()
- geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
- geometry.addGroup(0, 0, 0)
- geometry.addGroup(0, 0, 1)
- return geometry
- }, [])
+ // 2 groups map 1:1 to the stair body's 2-material array (body + tread).
+ const straightPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(2), [])
useEffect(() => {
return () => {
diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts
index 8928b3763..7616a3bcb 100644
--- a/packages/nodes/src/wall/floorplan-affordances.ts
+++ b/packages/nodes/src/wall/floorplan-affordances.ts
@@ -6,7 +6,6 @@ import {
getMaxWallCurveOffset,
getWallChordFrame,
normalizeWallCurveOffset,
- useAlignmentGuides,
useLiveNodeOverrides,
useScene,
type WallNode,
@@ -17,6 +16,7 @@ import {
isSegmentLongEnough,
snapScalarToGrid,
snapWallDraftPoint,
+ useAlignmentGuides,
WALL_FINE_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx
index e37ce12ec..2f5f56e0b 100644
--- a/packages/nodes/src/wall/move-endpoint-tool.tsx
+++ b/packages/nodes/src/wall/move-endpoint-tool.tsx
@@ -11,7 +11,6 @@ import {
pauseSceneHistory,
resolveAlignment,
resumeSceneHistory,
- useAlignmentGuides,
useScene,
type WallNode,
} from '@pascal-app/core'
@@ -24,9 +23,11 @@ import {
MeasurementPill,
type MovingWallEndpoint,
markToolCancelConsumed,
- snapWallDraftPoint,
+ snapWallDraftPointDetailed,
triggerSFX,
+ useAlignmentGuides,
useEditor,
+ useWallSnapIndicator,
WALL_FINE_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
@@ -291,12 +292,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
// for precision placement, so it can land on positions the
// active grid would skip (e.g. 0.05m increments when the active
// grid is 0.5m). It does NOT bypass snap.
- const snappedPoint = snapWallDraftPoint({
+ const snapResult = snapWallDraftPointDetailed({
point: planPoint,
walls: levelWalls,
ignoreWallIds: [nodeId],
step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined,
+ magnetic: useEditor.getState().magneticSnap,
})
+ const snappedPoint = snapResult.point
// Figma-style alignment: nudge the dragged endpoint onto another wall /
// fence endpoint or midpoint axis when within threshold, and publish a
@@ -327,11 +330,22 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
previousGridPosRef.current = alignedPoint
hasDraggedRef.current = true
+ // Stand the magnetic beacon at the endpoint when it locked onto existing
+ // wall geometry (corner / midpoint / crossing / wall body).
+ useWallSnapIndicator
+ .getState()
+ .set(
+ snapResult.snap
+ ? { x: alignedPoint[0], z: alignedPoint[1], kind: snapResult.snap }
+ : null,
+ )
+
applyPreview(alignedPoint, event.nativeEvent.altKey)
}
const onPointerUp = () => {
useAlignmentGuides.getState().clear()
+ useWallSnapIndicator.getState().clear()
// Press-release without drag: dismiss the tool without committing.
if (!hasDraggedRef.current) {
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -379,6 +393,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const onCancel = () => {
useAlignmentGuides.getState().clear()
+ useWallSnapIndicator.getState().clear()
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
@@ -425,6 +440,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
return () => {
useAlignmentGuides.getState().clear()
+ useWallSnapIndicator.getState().clear()
if (!wasCommitted) {
restoreOriginal(false)
}
diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx
index 24abf9b5f..d2d9f152b 100644
--- a/packages/nodes/src/wall/renderer.tsx
+++ b/packages/nodes/src/wall/renderer.tsx
@@ -3,7 +3,8 @@
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
-import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
+import type { Mesh } from 'three'
+import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
/**
* Thin wall renderer.
@@ -24,23 +25,11 @@ import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
* That decision lands in a later milestone; for now the system retains
* ownership of the rebuild loop.
*/
-function createEmptyWallGeometry(): BufferGeometry {
- const geometry = new BufferGeometry()
- geometry.setAttribute('position', new Float32BufferAttribute([], 3))
- geometry.addGroup(0, 0, 0)
- geometry.addGroup(0, 0, 1)
- geometry.addGroup(0, 0, 2)
- return geometry
-}
-
const WallRenderer = ({ node }: { node: WallNode }) => {
const ref = useRef(null!)
- const placeholderGeometry = useMemo(createEmptyWallGeometry, [])
- const collisionPlaceholderGeometry = useMemo(() => {
- const geometry = new BufferGeometry()
- geometry.setAttribute('position', new Float32BufferAttribute([], 3))
- return geometry
- }, [])
+ // 3 groups map 1:1 to the wall's 3-material array (see getVisibleWallMaterials).
+ const placeholderGeometry = useMemo(() => createPlaceholderGeometry(3), [])
+ const collisionPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(), [])
useRegistry(node.id, 'wall', ref)
diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx
index 67104a6a1..cc8d71679 100644
--- a/packages/nodes/src/wall/tool.tsx
+++ b/packages/nodes/src/wall/tool.tsx
@@ -7,7 +7,6 @@ import {
type LevelNode,
type Point2D,
resolveAlignment,
- useAlignmentGuides,
useScene,
type WallMiterData,
type WallNode,
@@ -22,9 +21,11 @@ import {
getSegmentAngleReferenceAtPoint,
markToolCancelConsumed,
type SegmentAngleReference,
- snapWallDraftPoint,
+ snapWallDraftPointDetailed,
triggerSFX,
+ useAlignmentGuides,
useEditor,
+ useWallSnapIndicator,
WALL_FINE_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
@@ -540,6 +541,7 @@ export const WallTool: React.FC = () => {
setDraftMeasurement(null)
setAxisGuide(null)
useAlignmentGuides.getState().clear()
+ useWallSnapIndicator.getState().clear()
}
const onGridMove = (event: GridEvent) => {
@@ -554,7 +556,22 @@ export const WallTool: React.FC = () => {
// a grid intersection.
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
const bypassAlign = event.nativeEvent?.altKey === true
- gridPosition = alignPoint(snapWallDraftPoint({ point: localPoint, walls, step }), bypassAlign)
+ const snapResult = snapWallDraftPointDetailed({
+ point: localPoint,
+ walls,
+ step,
+ magnetic: useEditor.getState().magneticSnap,
+ })
+ gridPosition = alignPoint(snapResult.point, bypassAlign)
+ // Stand the magnetic beacon at the endpoint when it locked onto an
+ // existing wall corner / wall point; clear it for plain grid/angle moves.
+ useWallSnapIndicator
+ .getState()
+ .set(
+ snapResult.snap
+ ? { x: gridPosition[0], z: gridPosition[1], kind: snapResult.snap }
+ : null,
+ )
if (buildingState.current === 1) {
const snappedLocal = gridPosition
@@ -617,7 +634,12 @@ export const WallTool: React.FC = () => {
if (buildingState.current === 0) {
const snappedStart = alignPoint(
- snapWallDraftPoint({ point: localClick, walls, step: clickStep }),
+ snapWallDraftPointDetailed({
+ point: localClick,
+ walls,
+ step: clickStep,
+ magnetic: useEditor.getState().magneticSnap,
+ }).point,
bypassAlign,
)
gridPosition = snappedStart
@@ -640,7 +662,12 @@ export const WallTool: React.FC = () => {
setDraftMeasurement(null)
} else if (buildingState.current === 1) {
const snappedEnd = alignPoint(
- snapWallDraftPoint({ point: localClick, walls, step: clickStep }),
+ snapWallDraftPointDetailed({
+ point: localClick,
+ walls,
+ step: clickStep,
+ magnetic: useEditor.getState().magneticSnap,
+ }).point,
bypassAlign,
)
const dx = snappedEnd[0] - startingPoint.current.x
@@ -657,6 +684,7 @@ export const WallTool: React.FC = () => {
// for the next segment, and drop the just-shown guide.
refreshAlignmentCandidates()
useAlignmentGuides.getState().clear()
+ useWallSnapIndicator.getState().clear()
const nextStart = createdWall.end
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
@@ -706,6 +734,7 @@ export const WallTool: React.FC = () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
useAlignmentGuides.getState().clear()
+ useWallSnapIndicator.getState().clear()
}
}, [unit])
diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx
index 031b795ce..59b39dee2 100644
--- a/packages/nodes/src/window/move-tool.tsx
+++ b/packages/nodes/src/window/move-tool.tsx
@@ -5,7 +5,6 @@ import {
isCurvedWall,
sceneRegistry,
spatialGridManager,
- useAlignmentGuides,
useLiveTransforms,
useScene,
type WallEvent,
@@ -19,6 +18,7 @@ import {
isValidWallSideFace,
snapToHalf,
triggerSFX,
+ useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx
index 36d718d0b..7abcfbe5b 100644
--- a/packages/nodes/src/window/tool.tsx
+++ b/packages/nodes/src/window/tool.tsx
@@ -5,7 +5,6 @@ import {
isCurvedWall,
sceneRegistry,
spatialGridManager,
- useAlignmentGuides,
useScene,
type WallEvent,
WindowNode,
@@ -18,6 +17,7 @@ import {
isValidWallSideFace,
snapToHalf,
triggerSFX,
+ useAlignmentGuides,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx
index 0c0e69089..6409a1416 100644
--- a/packages/viewer/src/components/viewer/index.tsx
+++ b/packages/viewer/src/components/viewer/index.tsx
@@ -4,6 +4,7 @@ import { type AnyNodeId, StairOpeningSystem } from '@pascal-app/core'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import * as THREE from 'three/webgpu'
+import { hasDrawableGeometry } from '../../lib/drawable-geometry'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import { applyIsolation, clearIsolation } from '../../lib/isolation'
import type { ColorPreset, RenderShading } from '../../lib/materials'
@@ -44,6 +45,62 @@ extend(THREE as any)
// renderers in parallel and only caching the second.
const WEBGPU_RENDERER_CACHE = new WeakMap>()
+const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet