Sudhir Yadav ce6f999310
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>
2026-06-08 13:14:39 -04:00

380 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
type AnyNodeId,
collectAlignmentAnchors,
DoorNode,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
} from '@pascal-app/core'
import {
calculateCursorRotation,
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
isValidWallSideFace,
triggerSFX,
useAlignmentGuides,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
/**
* Door tool — places DoorNodes on walls only.
* Doors always sit at floor level (clampedY = height/2).
*/
const DoorTool: React.FC = () => {
const draftRef = useRef<DoorNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
useEffect(() => {
useScene.temporal.getState().pause()
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const markWallDirty = (wallId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
}
const destroyDraft = () => {
if (!draftRef.current) return
const wallId = draftRef.current.parentId
useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null
if (wallId) markWallDirty(wallId)
}
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
}
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. A door aligns by the plan position of its centre.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
const group = cursorGroupRef.current
if (!group) return
group.visible = true
group.position.set(...worldPosition)
group.rotation.y = cursorRotationY
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
const levelId = getLevelId()
if (!levelId) return
if (event.node.parentId !== levelId) return
destroyDraft()
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const width = 0.9
const height = 2.1
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
const node = DoorNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
updateCursor(
wallLocalToWorld(
event.node,
clampedX,
clampedY,
getLevelYOffset(),
getSlabElevation(event),
),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
// Draft may be null after a successful placement (the click handler
// deletes it and relies on the wall rebuild → pointer-enter cascade to
// recreate it). Recreate it here on the first subsequent move so the
// preview is ready for the next click without requiring a leave/enter.
if (!draftRef.current) {
const levelId = getLevelId()
if (levelId && event.node.parentId === levelId) {
const node = DoorNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
}
}
if (draftRef.current) {
// Update the scene store on every move so the 2D floor plan
// stays in sync (it re-renders from `node.position`). Only
// forward `parentId` / `wallId` when the wall actually changed
// — otherwise the reparent path churns the host wall's
// `children` array every tick, which re-renders the wall and
// briefly draws its 0-vertex placeholder geometry (WebGPU then
// flags "Vertex buffer slot 0 ... was not set").
const isSameWall = event.node.id === draftRef.current.parentId
if (isSameWall) {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
})
markWallDirty(event.node.id)
} else {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
}
}
const valid = !hasWallChildOverlap(
event.node.id,
clampedX,
clampedY,
width,
height,
draftRef.current?.id,
)
updateCursor(
wallLocalToWorld(
event.node,
clampedX,
clampedY,
getLevelYOffset(),
getSlabElevation(event),
),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: draftRef.current.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
draftRef.current.width,
draftRef.current.height,
)
const valid = !hasWallChildOverlap(
event.node.id,
clampedX,
clampedY,
draftRef.current.width,
draftRef.current.height,
draftRef.current.id,
)
if (!valid) return
const draft = draftRef.current
draftRef.current = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
const levelId = getLevelId()
const state = useScene.getState()
const doorCount = Object.values(state.nodes).filter((n) => {
if (n.type !== 'door') return false
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return wall?.parentId === levelId
}).length
const name = `Door ${doorCount + 1}`
const node = DoorNode.parse({
name,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
width: draft.width,
height: draft.height,
doorCategory: draft.doorCategory,
doorType: draft.doorType,
leafCount: draft.leafCount,
operationState: draft.operationState,
slideDirection: draft.slideDirection,
trackStyle: draft.trackStyle,
garagePanelCount: draft.garagePanelCount,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
threshold: draft.threshold,
thresholdHeight: draft.thresholdHeight,
hingesSide: draft.hingesSide,
swingDirection: draft.swingDirection,
segments: draft.segments,
handle: draft.handle,
handleHeight: draft.handleHeight,
handleSide: draft.handleSide,
doorCloser: draft.doorCloser,
panicBar: draft.panicBar,
panicBarHeight: draft.panicBarHeight,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
event.stopPropagation()
}
const onWallLeave = () => {
destroyDraft()
hideCursor()
}
const onCancel = () => {
destroyDraft()
hideCursor()
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('tool:cancel', onCancel)
return () => {
destroyDraft()
hideCursor()
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
}, [])
// Cursor geometry: door outline (default 0.9 × 2.1 × 0.07)
const boxGeo = new BoxGeometry(0.9, 2.1, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments
geometry={edgesGeo}
layers={EDITOR_LAYER}
material={edgeMaterial}
ref={edgesRef}
/>
</group>
)
}
export default DoorTool