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() + +/** + * Renderer-level safety net against the empty-vertex-buffer crash. + * + * Wraps the per-object render function so any draw whose geometry has a count-0 + * `position` attribute is skipped instead of submitted. One such draw leaves + * WebGPU vertex buffer slot 0 unbound, which the validator rejects and which + * poisons the *whole* command encoder — so a single stray empty mesh (e.g. a + * transient placeholder, or a derived edge/outline geometry) flickers the entire + * canvas, not just itself. See `hasDrawableGeometry`. + * + * The custom render-object function is the documented three.js hook for this + * (`Renderer.setRenderObjectFunction`); it must call `renderObject()` for + * everything it keeps. `MergedOutlineNode` captures and restores this function + * around its passes, so the guard survives outline rendering (its own passes + * carry the same check inline). + */ +function installEmptyDrawGuard(renderer: THREE.WebGPURenderer) { + renderer.setRenderObjectFunction( + ( + object: any, + scene: any, + camera: any, + geometry: any, + material: any, + group: any, + lightsNode: any, + clippingContext: any, + passId: any, + ) => { + if (!hasDrawableGeometry(geometry)) { + if (warnedEmptyDraw && !warnedEmptyDraw.has(geometry ?? object)) { + warnedEmptyDraw.add(geometry ?? object) + console.warn( + '[viewer] skipped a draw with an empty position buffer (would poison the WebGPU command encoder)', + { name: object?.name, type: object?.type, material: material?.name }, + ) + } + return + } + ;(renderer as any).renderObject( + object, + scene, + camera, + geometry, + material, + group, + lightsNode, + clippingContext, + passId, + ) + }, + ) +} + /** * Monitors the WebGPU device for loss / uncaptured errors and logs them. * WebGPU device loss can happen when: @@ -245,6 +302,7 @@ const Viewer = forwardRef(function Viewer( useViewer.getState().sceneTheme, ).toneMappingExposure await renderer.init() + installEmptyDrawGuard(renderer) return renderer } catch (err) { // Drop the failed promise from the cache so a future Canvas diff --git a/packages/viewer/src/lib/drawable-geometry.ts b/packages/viewer/src/lib/drawable-geometry.ts new file mode 100644 index 000000000..d474923be --- /dev/null +++ b/packages/viewer/src/lib/drawable-geometry.ts @@ -0,0 +1,24 @@ +import type { BufferGeometry } from 'three' + +/** + * True when `geometry` has a bound, non-empty `position` attribute — i.e. it is + * safe to submit to the WebGPU renderer. + * + * A geometry whose `position` attribute has `count === 0` (or no `position` at + * all) leaves WebGPU **vertex buffer slot 0 unbound**. The validator rejects the + * draw with "Vertex buffer slot 0 … was not set", and — critically — that single + * rejected draw **poisons the entire command encoder**: every other draw in the + * frame (the whole scene + every editor overlay) is discarded on the next queue + * submit ("Invalid CommandBuffer"). The visible result is the whole canvas + * flickering/garbling, not just the offending mesh. + * + * Individual call-sites guard against *creating* empty geometry (see + * `createPlaceholderGeometry`, the ceiling/door degenerate fallbacks, etc.), but + * transient/derived geometries can still slip through. This predicate is the + * renderer-level safety net: skipping a count-0 draw is a no-op visually (it + * would draw nothing anyway) while keeping the command encoder healthy. + */ +export function hasDrawableGeometry(geometry: BufferGeometry | undefined | null): boolean { + const position = geometry?.attributes?.position + return Boolean(position && position.count > 0) +} diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts index e1bb5ff7a..4c621ffb8 100644 --- a/packages/viewer/src/lib/merged-outline-node.ts +++ b/packages/viewer/src/lib/merged-outline-node.ts @@ -49,6 +49,7 @@ import { SpriteNodeMaterial, TempNode, } from 'three/webgpu' +import { hasDrawableGeometry } from './drawable-geometry' const _quadMesh = new QuadMesh() const _size = new Vector2() @@ -353,6 +354,7 @@ export class MergedOutlineNode extends TempNode { renderer.setRenderTarget(this._depthRT) renderer.setRenderObjectFunction( (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => { + if (!hasDrawableGeometry(geo)) return const inCache = this._cacheA.has(obj) || this._cacheB.has(obj) if (!inCache) { const m = obj.isSprite ? this._depthSpriteMaterial : this._depthMaterial @@ -368,6 +370,7 @@ export class MergedOutlineNode extends TempNode { renderer.setRenderTarget(this._groupA.maskBuffer) renderer.setRenderObjectFunction( (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => { + if (!hasDrawableGeometry(geo)) return if (this._cacheA.has(obj)) { const m = obj.isSprite ? this._prepareMaskSpriteMatA : this._prepareMaskMatA renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip) @@ -383,6 +386,7 @@ export class MergedOutlineNode extends TempNode { renderer.setRenderTarget(this._groupB.maskBuffer) renderer.setRenderObjectFunction( (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => { + if (!hasDrawableGeometry(geo)) return if (this._cacheB.has(obj)) { const m = obj.isSprite ? this._prepareMaskSpriteMatB : this._prepareMaskMatB renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip) diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 43a2aa24a..496ec3336 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, type CeilingNode, getEffectiveNode, + nodeRegistry, sceneRegistry, useScene, } from '@pascal-app/core' @@ -9,6 +10,8 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' +type SceneNodes = ReturnType['nodes'] + function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') if (!uv) return @@ -38,7 +41,9 @@ export const CeilingSystem = () => { // Merge any live drag override so the polygon / height resize // arrow rebuilds the mesh at pointer rate — zustand only learns // the final value on commit. Mirrors WallSystem / GeometrySystem. - updateCeilingGeometry(getEffectiveNode(node as CeilingNode), mesh) + const effective = getEffectiveNode(node as CeilingNode) + const itemHoles = collectCeilingHoles(effective, nodes) + updateCeilingGeometry(effective, mesh, itemHoles) clearDirty(id as AnyNodeId) } // If mesh not found, keep it dirty for next frame @@ -48,11 +53,42 @@ export const CeilingSystem = () => { return null } +/** + * Collects ceiling-hole polygons from child nodes that declare the `ceilingCut` + * capability. Each child's `buildCeilingHole` returns a rotated-rectangle + * footprint in ceiling-local [x, z] space (or `null` to opt out), which is + * merged as an extra hole before triangulation. + * + * The viewer never branches on `child.type` — the dispatch goes through + * `nodeRegistry`, so any future kind (a heat lamp, a skylight panel, …) can + * participate just by declaring `capabilities.ceilingCut` on its definition. + */ +function collectCeilingHoles( + ceiling: CeilingNode, + nodes: SceneNodes, +): Array> { + const holes: Array> = [] + + for (const childId of ceiling.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (!child) continue + const def = nodeRegistry.get(child.type) + const hole = def?.capabilities?.ceilingCut?.buildCeilingHole(child) + if (hole) holes.push(hole) + } + + return holes +} + /** * Updates the geometry for a single ceiling */ -function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) { - const newGeo = generateCeilingGeometry(node) +function updateCeilingGeometry( + node: CeilingNode, + mesh: THREE.Mesh, + extraHoles: Array> = [], +) { + const newGeo = generateCeilingGeometry(node, extraHoles) mesh.geometry.dispose() mesh.geometry = newGeo @@ -74,13 +110,28 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) { } /** - * Generates flat ceiling geometry from polygon (no extrusion) + * Generates flat ceiling geometry from polygon (no extrusion). + * + * `extraHoles` are transient, derived cutouts (e.g. recessed-fixture + * footprints) that are cut alongside the node's persisted `holes` but never + * stored on the node — they are recomputed on every rebuild. */ -export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferGeometry { +export function generateCeilingGeometry( + ceilingNode: CeilingNode, + extraHoles: Array> = [], +): THREE.BufferGeometry { const polygon = ceilingNode.polygon if (polygon.length < 3) { - return new THREE.BufferGeometry() + // A degenerate ceiling (fewer than 3 points, e.g. mid-edit) still gets a + // non-empty position buffer — three zero-vertices forming one invisible + // triangle. An empty attribute (count 0) would leave WebGPU vertex buffer + // slot 0 unbound when this mesh (and its cloned grid overlay) is drawn, + // which the validator rejects ("slot 0 … was not set") and which poisons + // the whole command encoder. + const degenerate = new THREE.BufferGeometry() + degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + return degenerate } // Create shape from polygon @@ -97,8 +148,10 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG } shape.closePath() - // Add holes to the shape - const holes = mergeSurfaceHolePolygons(ceilingNode.holes || []) + // Add holes to the shape: persisted structural openings (stair/elevator/ + // manual, merged to dissolve overlaps) plus transient recessed-fixture + // cutouts. Both are in the same ceiling-local [x, z] space. + const holes = [...mergeSurfaceHolePolygons(ceilingNode.holes || []), ...extraHoles] for (const holePolygon of holes) { if (holePolygon.length < 3) continue diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 9e3b1e923..8e9397595 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -2276,6 +2276,22 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { } syncDoorCutout(node, mesh) + + // Guard: some degenerate door configs can leave a child mesh with an + // empty (0-vertex) geometry — e.g. a zero-area extruded leaf frame. + // Submitting such a mesh trips a WebGPU error ("Vertex buffer slot 0 + // … was not set" on a Draw(0, …)). Hide any empty mesh so it is never + // drawn (it would render nothing anyway). + hideEmptyGeometryMeshes(mesh) +} + +function hideEmptyGeometryMeshes(root: THREE.Object3D) { + root.traverse((obj) => { + const child = obj as THREE.Mesh + if (!child.isMesh || !child.geometry) return + const position = child.geometry.getAttribute('position') + if (!position || position.count === 0) child.visible = false + }) } function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { diff --git a/packages/viewer/src/systems/item-light/item-light-system.tsx b/packages/viewer/src/systems/item-light/item-light-system.tsx index b0e2185a9..2f0df245d 100644 --- a/packages/viewer/src/systems/item-light/item-light-system.tsx +++ b/packages/viewer/src/systems/item-light/item-light-system.tsx @@ -1,5 +1,5 @@ import type { AnyNodeId, LevelNode } from '@pascal-app/core' -import { sceneRegistry, useInteractive, useScene } from '@pascal-app/core' +import { findLevelAncestorId, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useRef } from 'react' import { MathUtils, type PointLight, Vector3 } from 'three' @@ -67,8 +67,7 @@ function scoreRegistration( const dist = _camPos.distanceTo(_itemPos) / 200 // ── Level factor ────────────────────────────────────────────────────────── - const node = nodes[nodeId] - const itemLevelId = node?.parentId ?? null + const itemLevelId = findLevelAncestorId(nodeId, nodes) let levelPenalty = 0 if (selectedLevelId) { @@ -135,7 +134,10 @@ export function ItemLightSystem() { scored.sort((a, b) => a.score - b.score) // Build the desired assignment (top POOL_SIZE keys) - const desired = scored.slice(0, POOL_SIZE).map((s) => s.key) + const desired = scored + .filter((s) => Number.isFinite(s.score)) + .slice(0, POOL_SIZE) + .map((s) => s.key) // Build a map of currently-assigned keys → slot index for hysteresis const currentlyAssigned = new Map() @@ -224,9 +226,11 @@ export function ItemLightSystem() { // Fade-out phase: lerp intensity → 0, then complete the transition if (slot.isFadingOut) { + light.visible = true light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) if (light.intensity < 0.01) { light.intensity = 0 + light.visible = false slot.isFadingOut = false slot.key = slot.pendingKey slot.pendingKey = null @@ -245,6 +249,7 @@ export function ItemLightSystem() { if (!slot.key) { // Idle slot — keep dark light.intensity = 0 + light.visible = false continue } @@ -252,6 +257,7 @@ export function ItemLightSystem() { if (!reg) { slot.key = null light.intensity = 0 + light.visible = false continue } @@ -275,7 +281,14 @@ export function ItemLightSystem() { ? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t) : reg.effect.intensityRange[0] + if (targetIntensity > 0) { + light.visible = true + } light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12) + if (targetIntensity <= 0 && light.intensity < 0.01) { + light.intensity = 0 + light.visible = false + } } }) @@ -286,6 +299,7 @@ export function ItemLightSystem() { castShadow={false} intensity={0} key={i} + visible={false} ref={(el: any) => { lightRefs.current[i] = el }} diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index e2e690949..704b27e80 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -144,7 +144,14 @@ export const RoofSystem = () => { if (mesh.geometry.type === 'BoxGeometry') { mesh.geometry.dispose() const placeholder = new THREE.BufferGeometry() - placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + // Three zero-vertices (one degenerate, invisible triangle), not an + // empty attribute: an empty position (count 0) leaves WebGPU vertex + // buffer slot 0 unbound if the mesh is ever drawn, and computeBoundsTree + // needs a real position buffer to index. + placeholder.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array(9), 3), + ) computeGeometryBoundsTree(placeholder) mesh.geometry = placeholder } diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index 242a6e5e7..d000bea2d 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -65,11 +65,9 @@ export const StairSystem = () => { } else if (isVisible) { return // Over budget — keep dirty, process next frame } else if (mesh.geometry.type === 'BoxGeometry') { - // Replace BoxGeometry placeholder with empty geometry + // Replace BoxGeometry placeholder with a non-drawing degenerate one. mesh.geometry.dispose() - const placeholder = new THREE.BufferGeometry() - placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) - mesh.geometry = placeholder + mesh.geometry = createEmptyGeometry() } clearDirty(id as AnyNodeId) } else { @@ -528,7 +526,11 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] { function createEmptyGeometry(): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + // Three zero-vertices (one degenerate, invisible triangle), not an empty + // attribute: an empty position (count 0) leaves WebGPU vertex buffer slot 0 + // unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"), + // poisoning the command encoder. The count-0 groups keep nothing drawn. + geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX) geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX) return geometry