arch: enforce layer boundaries — ceiling dispatch, store relocation, shared helper (#382)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

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

* fixed conflict

* editor: per-door-type floor-plan symbols

Render a distinct, static plan symbol for each door type in the
registry floor-plan builder (`packages/nodes/src/door/floorplan.ts`),
independent of the door's live open/close animation:

- single / hinged: fixed 90° swing with a dashed quarter-circle arc
- double / french: two mirrored half-width leaves + dashed arcs
- folding / bifold: static zigzag accordion (~80% span) on the wall face
- sliding: bypass — two overlapping panels on parallel tracks + arrow
- pocket: thin white leaf, ~60% closed, sliding into the solid wall
- barn: surface-mounted panel parked over the wall, dashed closed-ghost
  + slide arrow

The swing arc is dashed in screen-pixel units (the renderer uses
non-scaling-stroke). Symbols are oriented by hingesSide / swingDirection
/ slideDirection as appropriate.

Also includes pre-existing working-tree changes unrelated to the door
symbols: group move/rotate transform and box-select tweaks, and a
regenerated ifc-converter next-env.d.ts.

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

* Fix recessed ceiling fixtures and draw safety

* feat(editor): magnetic wall-snap with per-kind beacon (2D + 3D)

Snap the wall draft / endpoint-move point onto existing wall geometry —
corners, midpoints, wall–wall intersections, and along-wall edges — and
show a beacon at the snap point whose glyph encodes what it caught
(square = corner, triangle = midpoint, ✕ = intersection, circle = edge).

- Pure snap geometry extracted to wall-snap-geometry.ts (unit-tested).
- Ephemeral useWallSnapIndicator store drives a 3D pillar+glyph beacon
  and a 2D SVG glyph beacon, both indigo to match the alignment guides.
- Gated by a new persisted "Magnetic snap" toggle in the Display menu
  (useEditor); honored by draw + commit + endpoint-move in both views.

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

* editor: garage and open-doorway floor-plan symbols

Extend the per-door-type plan symbols in the registry floor-plan
builder (packages/nodes/src/door/floorplan.ts):

- open doorway (openingKind === 'opening'): bare gap, no leaf/arc/panel
  (mirrors the 3D system, which renders only the cutout for openings)
- garage sectional: closed leaf + side tracks into the garage + dashed
  parked ghost at the inner end
- garage roll-up: closed leaf + coil barrel (capsule) with a coil hint
- garage tilt-up: closed leaf + dashed parked panel + dashed curved
  up-and-over swing path
- gate the swing arc to actual swing doors (hinged/double/french) so
  other types fall back to the plain footprint

Garage mechanisms sit on the interior (door-local -z) side to match the
3D garage builders, independent of swingDirection.

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

* arch: enforce layer boundaries — registry dispatch, store relocation, shared helper

Three architectural fixes to bring the branch into full compliance:

1. **ceiling-system kind check → CeilingCutCapability**
   Replace `child.type === 'item'` branch in `ceiling-system` with registry
   dispatch. Add `CeilingCutCapability` type to `packages/core` registry types,
   implement `buildCeilingHole` on `itemDefinition`, and rewrite
   `collectRecessedItemHoles` → `collectCeilingHoles` to dispatch through
   `nodeRegistry` — viewer never again inspects a node's kind directly.

2. **useAlignmentGuides + useWallSnapIndicator → packages/editor**
   These stores are editor-only UI (snap beacons, alignment guides). Move them
   from `packages/core/src/store/` to `packages/editor/src/store/`, re-export
   from `packages/editor`, and update all 34 consumer files across
   `packages/editor` and `packages/nodes` to import from `@pascal-app/editor`.

3. **findLevelAncestorId extracted to core**
   `item-light-system` had a private `resolveNodeLevelId` that duplicated
   level-ancestor traversal logic. Extract it as `findLevelAncestorId` in
   `packages/core` (spatial-grid-sync), export it, and replace the local copy.

All four packages typecheck cleanly (zero errors).

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

* chore: fix lint, untrack .claude/launch.json

Run bun check --write to clear 8 Biome errors (formatting + import order
+ one unused import). Untrack .claude/launch.json and add it plus
.claude/settings.local.json to .gitignore so local IDE/agent configs
stop landing in commits.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sudhir Yadav
2026-06-08 13:14:39 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 7796837081
commit ce6f999310
76 changed files with 1987 additions and 338 deletions
@@ -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<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet<object>()
/**
* 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<ViewerHandle, ViewerProps>(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
@@ -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)
}
@@ -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)
@@ -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<typeof useScene.getState>['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<Array<[number, number]>> {
const holes: Array<Array<[number, number]>> = []
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<Array<[number, number]>> = [],
) {
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<Array<[number, number]>> = [],
): 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
@@ -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) {
@@ -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<string, number>()
@@ -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
}}
@@ -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
}
@@ -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