refactor: release-review cleanup for roof wall openings

Dual review pass (Claude multi-angle + Codex release-quality). One
correctness fix and the agreed do-now cleanups:

- fix: clone-scene-graph remaps roofSegmentId like wallId in both
  clone paths — duplicated scenes/levels kept pointing roof-hosted
  children at the original segments.
- extract the settled, stateless roof target/cursor math shared by the
  four door/window tools into shared/roof-wall-opening-placement.ts
  (resolveRoofWallOpeningTarget + getRoofWallOpeningCursorPose +
  worldToSelectedBuildingLocal); tools keep the stateful lifecycle
  (drafts, undo/temporal, commit field lists). −199 net lines.
- rename host-generic state: currentWallId→currentHostId,
  markWallDirty→markHostDirty (they hold segment ids too); capability
  cascadesViaHostSegment→dirtyHandledByOwnSystem (behavior-facing,
  before the public API hardens).
- drop getRoofAccessoryKinds from core's public API — its only caller
  was the standalone Build tab, which now enumerates the registry
  inline with its app-specific filter.
- window move-tool uses the shared stripPlacementMetadataFlags; stale
  "segment-local" comment fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-10 14:33:13 -04:00
co-authored by Claude Fable 5
parent b8dfa90762
commit aa3b0ef758
16 changed files with 349 additions and 435 deletions
+16 -15
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { getRoofAccessoryKinds, nodeRegistry } from '@pascal-app/core' import { nodeRegistry } from '@pascal-app/core'
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
import Image from 'next/image' import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -87,9 +87,10 @@ const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.png'
/** /**
* Roof accessories surfaced under the Roof tile (a "Features" group). Unlike * Roof accessories surfaced under the Roof tile (a "Features" group). Unlike
* the community editor these aren't DB presets — each is a registry kind with * the community editor these aren't DB presets — each is a registry kind with
* `capabilities.roofAccessory`, discovered via `getRoofAccessoryKinds()` and * `capabilities.roofAccessory`, enumerated from the registry at render time
* activated like any structure tool (the kind's tool attaches it to the roof * (it is populated by the app bootstrap — a module-scope const would race it)
* segment under the cursor). Label + icon come from the registry's * and activated like any structure tool (the kind's tool attaches it to the
* roof segment under the cursor). Label + icon come from the registry's
* `presentation`; non-url icons fall back to the roof icon. * `presentation`; non-url icons fall back to the roof icon.
*/ */
function activateRoofFeatureTool(kind: string): void { function activateRoofFeatureTool(kind: string): void {
@@ -116,23 +117,23 @@ export function BuildTab() {
// Read at render time (not module scope): the registry is populated by the // Read at render time (not module scope): the registry is populated by the
// app bootstrap, so enumerating earlier would race it and see no kinds. // app bootstrap, so enumerating earlier would race it and see no kinds.
const roofFeatures = useMemo<RoofFeature[]>( const roofFeatures = useMemo<RoofFeature[]>(() => {
() => const features: RoofFeature[] = []
getRoofAccessoryKinds() for (const [kind, def] of nodeRegistry.entries()) {
if (def.capabilities.roofAccessory === undefined) continue
// Door / window declare `roofAccessory` for the wall-face cut but // Door / window declare `roofAccessory` for the wall-face cut but
// already have their own Build tiles — listing them here too // already have their own Build tiles — listing them here too
// would duplicate the entry under Roof → Features. // would duplicate the entry under Roof → Features.
.filter((kind) => !nodeRegistry.get(kind)?.capabilities?.wallOpeningPlacement) if (def.capabilities.wallOpeningPlacement) continue
.map((kind) => { const icon = def.presentation?.icon
const icon = nodeRegistry.get(kind)?.presentation?.icon features.push({
return {
kind, kind,
label: nodeRegistry.get(kind)?.presentation?.label ?? kind, label: def.presentation?.label ?? kind,
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
})
} }
}), return features
[], }, [])
)
const isTypeActive = (type: BuildType) => const isTypeActive = (type: BuildType) =>
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id
-1
View File
@@ -17,7 +17,6 @@ export type {
export { export {
discoverPlugins, discoverPlugins,
getHostRefFields, getHostRefFields,
getRoofAccessoryKinds,
getSelectableKinds, getSelectableKinds,
isDrawnViaTool, isDrawnViaTool,
isDrawnViaToolKind, isDrawnViaToolKind,
-22
View File
@@ -114,28 +114,6 @@ export function isRegistrySelectable(kind: string): boolean {
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined return nodeRegistry.get(kind)?.capabilities.selectable !== undefined
} }
/**
* Kinds whose definition declares the `roofAccessory` capability — the roof
* accessories (dormer, chimney, vents, gutter, …) that mount onto a roof
* segment via their own attach tool. Lets host UIs surface a "Features" group
* under the roof category without hardcoding the kind list (the standalone
* editor's Build tab; the roof inspector's add menu). Returned in builtin
* registration order (`packages/nodes/src/index.ts`), which is deterministic.
*
* Call at render time, not module-import time: the registry is populated by
* the host's bootstrap (`loadPlugin`), so a top-level `const` would race it
* and see an empty registry.
*/
export function getRoofAccessoryKinds(): string[] {
const result: string[] = []
for (const [kind, def] of nodeRegistry.entries()) {
if (def.capabilities.roofAccessory !== undefined) {
result.push(kind)
}
}
return result
}
/** /**
* Kinds whose `def.floorplanScope` matches the requested scope. Used by * Kinds whose `def.floorplanScope` matches the requested scope. Used by
* `FloorplanRegistryLayer` to discover building-scoped kinds (e.g. * `FloorplanRegistryLayer` to discover building-scoped kinds (e.g.
+7 -7
View File
@@ -1214,14 +1214,14 @@ export type RoofAccessoryConfig = {
*/ */
cutScope?: 'all' | 'wall' cutScope?: 'all' | 'wall'
/** /**
* Set when the kind runs its own dirty-driven geometry system that * The kind's own dirty-driven geometry system consumes its dirty
* already cascades to the host segment (door / window via the * marks (door / window via DoorSystem / WindowSystem, which already
* DoorSystem / WindowSystem `parentId` cascade). The roof-merge loop * cascade to the host segment through `parentId`). The roof-merge
* must then leave the kind's dirty marks alone — consuming them here * loop must then leave those marks alone — consuming them would
* would starve that system whenever it defers a rebuild (mesh not * starve that system whenever it defers a rebuild (mesh not mounted
* mounted yet, per-frame rebuild budget exhausted). * yet, per-frame rebuild budget exhausted).
*/ */
cascadesViaHostSegment?: boolean dirtyHandledByOwnSystem?: boolean
} }
/** /**
@@ -76,6 +76,13 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
| undefined | undefined
} }
// Remap roofSegmentId (doors/windows/items hosted on roof wall faces)
if ('roofSegmentId' in clonedNode && typeof clonedNode.roofSegmentId === 'string') {
;(clonedNode as Record<string, unknown>).roofSegmentId = idMap.get(
clonedNode.roofSegmentId,
) as string | undefined
}
clonedNodes[newId] = clonedNode clonedNodes[newId] = clonedNode
} }
@@ -220,6 +227,12 @@ export function cloneLevelSubtree(
;(cloned as Record<string, unknown>).wallId = idMap.get(cloned.wallId) ?? cloned.wallId ;(cloned as Record<string, unknown>).wallId = idMap.get(cloned.wallId) ?? cloned.wallId
} }
// Remap roofSegmentId (doors/windows/items hosted on roof wall faces)
if ('roofSegmentId' in cloned && typeof cloned.roofSegmentId === 'string') {
;(cloned as Record<string, unknown>).roofSegmentId =
idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId
}
clonedNodes.push(cloned) clonedNodes.push(cloned)
} }
@@ -55,6 +55,7 @@ import {
NO_RAYCAST, NO_RAYCAST,
} from './handles/handle-arrow' } from './handles/handle-arrow'
import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
// Pooled scratch for the handle rig's world-relative pose mapping. // Pooled scratch for the handle rig's world-relative pose mapping.
const _rigRelative = new Matrix4() const _rigRelative = new Matrix4()
const _rigScratchScale = new Vector3() const _rigScratchScale = new Vector3()
@@ -363,7 +363,7 @@ type RoofWallTarget = {
segment: RoofSegmentNode segment: RoofSegmentNode
faceId: RoofWallFaceId faceId: RoofWallFaceId
faceYaw: number faceYaw: number
/** Stored node position: segment-local, y = bottom edge. */ /** Stored node position: FACE-LOCAL, y = bottom edge. */
position: [number, number, number] position: [number, number, number]
/** Face-coord center of the placed rect (for the overlap guard). */ /** Face-coord center of the placed rect (for the overlap guard). */
centerU: number centerU: number
@@ -1062,9 +1062,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// push (z = thickness/2 off the face frame's mid-plane) so the // push (z = thickness/2 off the face frame's mid-plane) so the
// drag preview doesn't sink into the wall until commit. // drag preview doesn't sink into the wall until commit.
if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) { if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
const segment = useScene.getState().nodes[ const segment =
placementState.current.roofSegmentId as AnyNodeId useScene.getState().nodes[placementState.current.roofSegmentId as AnyNodeId]
]
if (segment?.type === 'roof-segment') { if (segment?.type === 'roof-segment') {
mesh.position.z = (segment.wallThickness ?? 0.1) / 2 mesh.position.z = (segment.wallThickness ?? 0.1) / 2
} }
+2 -2
View File
@@ -160,14 +160,14 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
wallOpeningPlacement: true, wallOpeningPlacement: true,
// Doors also host on roof-segment wall faces (base walls under the // Doors also host on roof-segment wall faces (base walls under the
// roof, gable ends). `buildCut` punches the opening into the // roof, gable ends). `buildCut` punches the opening into the
// segment's wall brush; `cascadesViaHostSegment` keeps the roof-merge // segment's wall brush; `dirtyHandledByOwnSystem` keeps the roof-merge
// loop from consuming door dirty marks (DoorSystem owns them and // loop from consuming door dirty marks (DoorSystem owns them and
// already cascades to the host via parentId). // already cascades to the host via parentId).
roofAccessory: { roofAccessory: {
buildCut: (node, hostSegment) => buildCut: (node, hostSegment) =>
buildRoofWallOpeningCut(node as DoorNodeType, hostSegment as RoofSegmentNode), buildRoofWallOpeningCut(node as DoorNodeType, hostSegment as RoofSegmentNode),
cutScope: 'wall', cutScope: 'wall',
cascadesViaHostSegment: true, dirtyHandledByOwnSystem: true,
}, },
// `wallId` / `roofSegmentId` tie the door to its host and are // `wallId` / `roofSegmentId` tie the door to its host and are
// re-derived from the surface under the cursor when a preset is // re-derived from the surface under the cursor when a preset is
+57 -102
View File
@@ -1,13 +1,11 @@
import { import {
type AnyNodeId, type AnyNodeId,
clampRectToRoofWallFace,
collectAlignmentAnchors, collectAlignmentAnchors,
DoorNode, DoorNode,
emitter, emitter,
isCurvedWall, isCurvedWall,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
roofFacePointToSegment,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveTransforms, useLiveTransforms,
@@ -19,9 +17,7 @@ import {
calculateItemRotation, calculateItemRotation,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace, isValidWallSideFace,
resolveRoofWallHit,
stripPlacementMetadataFlags, stripPlacementMetadataFlags,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
@@ -29,8 +25,13 @@ import {
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
resolveRoofWallOpeningTarget,
type RoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
@@ -41,7 +42,6 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false, depthWrite: false,
}) })
const roofCursorPoint = new Vector3()
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
const cursorGroupRef = useRef<Group>(null!) const cursorGroupRef = useRef<Group>(null!)
@@ -79,7 +79,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}) })
} }
let currentWallId: string | null = movingDoorNode.parentId let currentHostId: string | null = movingDoorNode.parentId
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
let lastTarget: { let lastTarget: {
wallNode: WallEvent['node'] wallNode: WallEvent['node']
@@ -93,18 +93,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
event: WallEvent event: WallEvent
} | null = null } | null = null
const markWallDirty = (wallId: string | null) => { const markHostDirty = (hostId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
} }
const lastWallDirtyAt = new Map<string, number>() const lastHostDirtyAt = new Map<string, number>()
const markWallDirtyThrottled = (wallId: string | null) => { const markHostDirtyThrottled = (hostId: string | null) => {
if (!wallId) return if (!hostId) return
const now = globalThis.performance?.now?.() ?? Date.now() const now = globalThis.performance?.now?.() ?? Date.now()
const last = lastWallDirtyAt.get(wallId) ?? 0 const last = lastHostDirtyAt.get(hostId) ?? 0
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse. // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
if (now - last > 120) { if (now - last > 120) {
lastWallDirtyAt.set(wallId, now) lastHostDirtyAt.set(hostId, now)
markWallDirty(wallId) markHostDirty(hostId)
} }
} }
@@ -213,7 +213,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
} }
const applyPreview = (target: NonNullable<typeof lastTarget>) => { const applyPreview = (target: NonNullable<typeof lastTarget>) => {
if (currentWallId !== target.wallId) { if (currentHostId !== target.wallId) {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: [target.clampedX, target.clampedY, 0], position: [target.clampedX, target.clampedY, 0],
rotation: [0, target.itemRotation, 0], rotation: [0, target.itemRotation, 0],
@@ -223,8 +223,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined, roofFace: undefined,
}) })
markWallDirty(currentWallId) markHostDirty(currentHostId)
currentWallId = target.wallId currentHostId = target.wallId
} else { } else {
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId) const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
if (doorMesh) { if (doorMesh) {
@@ -237,7 +237,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
position: [target.clampedX, target.clampedY, 0], position: [target.clampedX, target.clampedY, 0],
rotation: target.itemRotation, rotation: target.itemRotation,
}) })
markWallDirtyThrottled(target.wallId) markHostDirtyThrottled(target.wallId)
updateCursor( updateCursor(
wallLocalToWorld( wallLocalToWorld(
@@ -328,12 +328,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}) })
if (original.parentId && original.parentId !== target.wallId) { if (original.parentId && original.parentId !== target.wallId) {
markWallDirty(original.parentId) markHostDirty(original.parentId)
} }
placedId = movingDoorNode.id placedId = movingDoorNode.id
} }
markWallDirty(target.wallId) markHostDirty(target.wallId)
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -350,10 +350,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
if (isNew) return if (isNew) return
if (currentWallId && currentWallId !== original.parentId) { if (currentHostId && currentHostId !== original.parentId) {
markWallDirty(currentWallId) markHostDirty(currentHostId)
} }
currentWallId = original.parentId currentHostId = original.parentId
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: original.position, position: original.position,
rotation: original.rotation, rotation: original.rotation,
@@ -363,7 +363,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markHostDirty(original.parentId)
} }
// ── Roof-segment wall faces ───────────────────────────────────── // ── Roof-segment wall faces ─────────────────────────────────────
@@ -371,63 +371,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// walls under the roof + coplanar gable ends). This is also the // walls under the roof + coplanar gable ends). This is also the
// placement path preset tiles take (`metadata.isNew` clones). // placement path preset tiles take (`metadata.isNew` clones).
const worldToBuildingLocal = (point: Vector3): [number, number, number] => { const resolveRoofMoveTarget = (event: RoofEvent) =>
const buildingId = useViewer.getState().selection.buildingId resolveRoofWallOpeningTarget({
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined event,
if (buildingObj) buildingObj.worldToLocal(point) width: movingDoorNode.width,
return [point.x, point.y, point.z] height: movingDoorNode.height,
} ignoreId: movingDoorNode.id,
vertical: { kind: 'bottom-locked' },
})
const resolveRoofMoveTarget = (event: RoofEvent) => { const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const hit = resolveRoofWallHit( const pose = getRoofWallOpeningCursorPose(target, roof)
event.node as RoofNode, if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
event.position,
event.normal,
event.object,
)
if (!hit) return null
// Doors sit on the segment base: v locked to height/2, only u slides.
const clamped = clampRectToRoofWallFace(
hit.face,
hit.u,
movingDoorNode.height / 2,
movingDoorNode.width,
movingDoorNode.height,
{ lockV: true },
)
if (!clamped) return null
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
// mounts the node inside the live face frame, so it tracks segment
// resizes without any re-anchoring.
const position: [number, number, number] = [clamped.u, clamped.v, 0]
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
movingDoorNode.width,
movingDoorNode.height,
movingDoorNode.id,
)
return { hit, position, valid, roof: event.node as RoofNode }
}
const updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return
segObj.updateWorldMatrix(true, false)
const segLocal = roofFacePointToSegment(
target.hit.segment,
target.hit.face.id,
target.position,
)
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
target.valid,
)
} }
const onRoofHover = (event: RoofEvent) => { const onRoofHover = (event: RoofEvent) => {
@@ -437,33 +392,33 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
if (currentWallId !== target.hit.segment.id) { if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: target.position, position: target.position,
rotation: [0, 0, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
parentId: target.hit.segment.id, parentId: target.segment.id,
wallId: undefined, wallId: undefined,
roofSegmentId: target.hit.segment.id, roofSegmentId: target.segment.id,
roofFace: target.hit.face.id, roofFace: target.face.id,
}) })
markWallDirty(currentWallId) markHostDirty(currentHostId)
currentWallId = target.hit.segment.id currentHostId = target.segment.id
} else { } else {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: target.position, position: target.position,
rotation: [0, 0, 0], rotation: [0, 0, 0],
roofFace: target.hit.face.id, roofFace: target.face.id,
}) })
} }
updateRoofCursor(target) updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation() event.stopPropagation()
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event) const target = resolveRoofMoveTarget(event)
if (!target?.valid) return if (!target?.valid) return
const segmentId = target.hit.segment.id const segmentId = target.segment.id
let placedId: string let placedId: string
@@ -481,7 +436,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: 'front', side: 'front',
wallId: undefined, wallId: undefined,
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.hit.face.id, roofFace: target.face.id,
parentId: segmentId, parentId: segmentId,
}) })
useScene.getState().createNode(node, segmentId as AnyNodeId) useScene.getState().createNode(node, segmentId as AnyNodeId)
@@ -506,17 +461,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: segmentId, parentId: segmentId,
wallId: undefined, wallId: undefined,
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.hit.face.id, roofFace: target.face.id,
metadata: {}, metadata: {},
}) })
if (original.parentId && original.parentId !== segmentId) { if (original.parentId && original.parentId !== segmentId) {
markWallDirty(original.parentId) markHostDirty(original.parentId)
} }
placedId = movingDoorNode.id placedId = movingDoorNode.id
} }
markWallDirty(segmentId) markHostDirty(segmentId)
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -533,10 +488,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
if (isNew) return if (isNew) return
if (currentWallId && currentWallId !== original.parentId) { if (currentHostId && currentHostId !== original.parentId) {
markWallDirty(currentWallId) markHostDirty(currentHostId)
} }
currentWallId = original.parentId currentHostId = original.parentId
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: original.position, position: original.position,
rotation: original.rotation, rotation: original.rotation,
@@ -546,14 +501,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markHostDirty(original.parentId)
} }
const onCancel = () => { const onCancel = () => {
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
if (isNew) { if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id) useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId) if (currentHostId) markHostDirty(currentHostId)
} else { } else {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: original.position, position: original.position,
@@ -565,7 +520,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markHostDirty(original.parentId)
} }
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
hideCursor() hideCursor()
@@ -590,7 +545,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (currentMeta?.isTransient) { if (currentMeta?.isTransient) {
if (isNew) { if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id) useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId) if (currentHostId) markHostDirty(currentHostId)
} else { } else {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: original.position, position: original.position,
@@ -602,7 +557,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markHostDirty(original.parentId)
} }
} }
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
+33 -80
View File
@@ -1,13 +1,11 @@
import { import {
type AnyNodeId, type AnyNodeId,
clampRectToRoofWallFace,
collectAlignmentAnchors, collectAlignmentAnchors,
DoorNode, DoorNode,
emitter, emitter,
isCurvedWall, isCurvedWall,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
roofFacePointToSegment,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -18,16 +16,19 @@ import {
calculateItemRotation, calculateItemRotation,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace, isValidWallSideFace,
resolveRoofWallHit,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
resolveRoofWallOpeningTarget,
type RoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
@@ -38,7 +39,6 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false, depthWrite: false,
}) })
const roofCursorPoint = new Vector3()
/** /**
* Door tool — places DoorNodes on walls and on roof-segment wall faces * Door tool — places DoorNodes on walls and on roof-segment wall faces
@@ -66,8 +66,8 @@ const DoorTool: React.FC = () => {
wallEvent.node.end, wallEvent.node.end,
) )
const markWallDirty = (wallId: string) => { const markHostDirty = (hostId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
} }
const destroyDraft = () => { const destroyDraft = () => {
@@ -75,7 +75,7 @@ const DoorTool: React.FC = () => {
const wallId = draftRef.current.parentId const wallId = draftRef.current.parentId
useScene.getState().deleteNode(draftRef.current.id) useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null draftRef.current = null
if (wallId) markWallDirty(wallId) if (wallId) markHostDirty(wallId)
} }
const hideCursor = () => { const hideCursor = () => {
@@ -217,7 +217,7 @@ const DoorTool: React.FC = () => {
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
side, side,
}) })
markWallDirty(event.node.id) markHostDirty(event.node.id)
} else { } else {
useScene.getState().updateNode(draftRef.current.id, { useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0], position: [clampedX, clampedY, 0],
@@ -352,65 +352,18 @@ const DoorTool: React.FC = () => {
// The merged roof mesh emits `roof:*`; hits are resolved against the // The merged roof mesh emits `roof:*`; hits are resolved against the
// segments' vertical wall faces (base walls + coplanar gable ends). // segments' vertical wall faces (base walls + coplanar gable ends).
const worldToBuildingLocal = (point: Vector3): [number, number, number] => { const resolveRoofTarget = (event: RoofEvent) =>
// The tool's cursor group renders in the building's local frame — resolveRoofWallOpeningTarget({
// same conversion as the roof accessory tools (e.g. SkylightTool). event,
const buildingId = useViewer.getState().selection.buildingId width: draftRef.current?.width ?? 0.9,
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined height: draftRef.current?.height ?? 2.1,
if (buildingObj) buildingObj.worldToLocal(point) ignoreId: draftRef.current?.id,
return [point.x, point.y, point.z] vertical: { kind: 'bottom-locked' },
}
const resolveRoofTarget = (event: RoofEvent) => {
const hit = resolveRoofWallHit(
event.node as RoofNode,
event.position,
event.normal,
event.object,
)
if (!hit) return null
const width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
// Doors sit on the segment base: v locked to height/2, only u slides.
const clamped = clampRectToRoofWallFace(hit.face, hit.u, height / 2, width, height, {
lockV: true,
}) })
if (!clamped) return null
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
// mounts the node inside the live face frame, so it tracks segment
// resizes without any re-anchoring.
const position: [number, number, number] = [clamped.u, clamped.v, 0]
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
width,
height,
draftRef.current?.id,
)
return { hit, position, valid }
}
const updateRoofCursor = ( const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
target: NonNullable<ReturnType<typeof resolveRoofTarget>>, const pose = getRoofWallOpeningCursorPose(target, roof)
roof: RoofNode, if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
) => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return
segObj.updateWorldMatrix(true, false)
const segLocal = roofFacePointToSegment(
target.hit.segment,
target.hit.face.id,
target.position,
)
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
target.valid,
)
} }
const onRoofHover = (event: RoofEvent) => { const onRoofHover = (event: RoofEvent) => {
@@ -424,26 +377,26 @@ const DoorTool: React.FC = () => {
} }
return return
} }
const { hit, position } = target const { segment, face, position } = target
if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft() if (draftRef.current && draftRef.current.parentId !== segment.id) destroyDraft()
if (draftRef.current) { if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, { useScene.getState().updateNode(draftRef.current.id, {
position, position,
rotation: [0, 0, 0], rotation: [0, 0, 0],
roofFace: hit.face.id, roofFace: face.id,
}) })
} else { } else {
const node = DoorNode.parse({ const node = DoorNode.parse({
position, position,
rotation: [0, 0, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
roofSegmentId: hit.segment.id, roofSegmentId: segment.id,
roofFace: hit.face.id, roofFace: face.id,
parentId: hit.segment.id, parentId: segment.id,
metadata: { isTransient: true }, metadata: { isTransient: true },
}) })
useScene.getState().createNode(node, hit.segment.id as AnyNodeId) useScene.getState().createNode(node, segment.id as AnyNodeId)
draftRef.current = node draftRef.current = node
} }
updateRoofCursor(target, event.node as RoofNode) updateRoofCursor(target, event.node as RoofNode)
@@ -454,7 +407,7 @@ const DoorTool: React.FC = () => {
if (!draftRef.current?.roofSegmentId) return if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event) const target = resolveRoofTarget(event)
if (!target?.valid) return if (!target?.valid) return
const { hit, position } = target const { segment, face, position } = target
const draft = draftRef.current const draft = draftRef.current
draftRef.current = null draftRef.current = null
@@ -472,9 +425,9 @@ const DoorTool: React.FC = () => {
position, position,
rotation: [0, 0, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
roofSegmentId: hit.segment.id, roofSegmentId: segment.id,
roofFace: hit.face.id, roofFace: face.id,
parentId: hit.segment.id, parentId: segment.id,
width: draft.width, width: draft.width,
height: draft.height, height: draft.height,
doorCategory: draft.doorCategory, doorCategory: draft.doorCategory,
@@ -499,10 +452,10 @@ const DoorTool: React.FC = () => {
panicBarHeight: draft.panicBarHeight, panicBarHeight: draft.panicBarHeight,
}) })
useScene.getState().createNode(node, hit.segment.id as AnyNodeId) useScene.getState().createNode(node, segment.id as AnyNodeId)
// Rebuild the segment (and the merged roof) so the wall brush // Rebuild the segment (and the merged roof) so the wall brush
// picks up the new opening cut. // picks up the new opening cut.
useScene.getState().dirtyNodes.add(hit.segment.id as AnyNodeId) useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build') triggerSFX('sfx:structure-build')
@@ -0,0 +1,113 @@
import {
type AnyNodeId,
clampRectToRoofWallFace,
type RoofEvent,
type RoofNode,
type RoofSegmentNode,
type RoofSegmentWallFace,
roofFacePointToSegment,
sceneRegistry,
} from '@pascal-app/core'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Vector3 } from 'three'
/**
* Stateless target/cursor math shared by the door and window placement
* + move tools' roof flows. The tools keep ownership of everything
* stateful (draft lifecycle, undo/temporal sequencing, commit field
* lists, SFX/selection) — only the settled geometry lives here.
*/
export type RoofWallOpeningTarget = {
segment: RoofSegmentNode
face: RoofSegmentWallFace
/** FACE-LOCAL stored position: [u, v-center, 0] on the wall mid-plane. */
position: [number, number, number]
/** False when the rect overlaps a sibling on the same face. */
valid: boolean
}
export type RoofWallOpeningVertical =
/** Doors: bottom on the segment base, only `u` slides. */
| { kind: 'bottom-locked' }
/** Windows: free height, optionally grid-snapped before the clamp. */
| { kind: 'free'; snap?: (v: number) => number }
/**
* Resolve a roof pointer event to an opening placement on a segment
* wall face: hit → vertical policy → profile clamp → overlap check.
* Null when the pointer isn't over a placeable face or the rect cannot
* fit at that spot.
*/
export function resolveRoofWallOpeningTarget(args: {
event: RoofEvent
width: number
height: number
ignoreId?: string
vertical: RoofWallOpeningVertical
}): RoofWallOpeningTarget | null {
const { event, width, height, ignoreId, vertical } = args
const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
if (!hit) return null
const centerV = vertical.kind === 'bottom-locked' ? height / 2 : (vertical.snap?.(hit.v) ?? hit.v)
const clamped = clampRectToRoofWallFace(
hit.face,
hit.u,
centerV,
width,
height,
vertical.kind === 'bottom-locked' ? { lockV: true } : undefined,
)
if (!clamped) return null
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
width,
height,
ignoreId,
)
return {
segment: hit.segment,
face: hit.face,
position: [clamped.u, clamped.v, 0],
valid,
}
}
const cursorPoint = new Vector3()
/**
* World → building-local. Tool cursor groups render inside the
* building's frame (same conversion as the roof accessory tools).
*/
export function worldToSelectedBuildingLocal(point: Vector3): [number, number, number] {
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
if (buildingObj) buildingObj.worldToLocal(point)
return [point.x, point.y, point.z]
}
/**
* Cursor pose for a resolved target: building-local position of the
* opening center + total yaw (roof ∘ segment ∘ face).
*/
export function getRoofWallOpeningCursorPose(
target: RoofWallOpeningTarget,
roof: RoofNode,
): { position: [number, number, number]; rotationY: number } | null {
const segObj = sceneRegistry.nodes.get(target.segment.id as AnyNodeId)
if (!segObj) return null
segObj.updateWorldMatrix(true, false)
const segLocal = roofFacePointToSegment(target.segment, target.face.id, target.position)
cursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(cursorPoint)
return {
position: worldToSelectedBuildingLocal(cursorPoint),
rotationY: (roof.rotation ?? 0) + (target.segment.rotation ?? 0) + target.face.yaw,
}
}
+2 -2
View File
@@ -152,12 +152,12 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
wallOpeningPlacement: true, wallOpeningPlacement: true,
// Windows also host on roof-segment wall faces (base walls under the // Windows also host on roof-segment wall faces (base walls under the
// roof, gable ends) — same wiring as door; see the door capability // roof, gable ends) — same wiring as door; see the door capability
// for why `cascadesViaHostSegment` is required. // for why `dirtyHandledByOwnSystem` is required.
roofAccessory: { roofAccessory: {
buildCut: (node, hostSegment) => buildCut: (node, hostSegment) =>
buildRoofWallOpeningCut(node as WindowNodeType, hostSegment as RoofSegmentNode), buildRoofWallOpeningCut(node as WindowNodeType, hostSegment as RoofSegmentNode),
cutScope: 'wall', cutScope: 'wall',
cascadesViaHostSegment: true, dirtyHandledByOwnSystem: true,
}, },
// `wallId` / `roofSegmentId` are re-derived from the surface under // `wallId` / `roofSegmentId` are re-derived from the surface under
// the cursor at preset placement time — see door for the pattern. // the cursor at preset placement time — see door for the pattern.
+60 -111
View File
@@ -1,12 +1,10 @@
import { import {
type AnyNodeId, type AnyNodeId,
clampRectToRoofWallFace,
collectAlignmentAnchors, collectAlignmentAnchors,
emitter, emitter,
isCurvedWall, isCurvedWall,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
roofFacePointToSegment,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveTransforms, useLiveTransforms,
@@ -19,18 +17,22 @@ import {
calculateItemRotation, calculateItemRotation,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace, isValidWallSideFace,
resolveRoofWallHit,
snapToHalf, snapToHalf,
stripPlacementMetadataFlags,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
useEditor, useEditor,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
resolveRoofWallOpeningTarget,
type RoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -41,7 +43,6 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false, depthWrite: false,
}) })
const roofCursorPoint = new Vector3()
/** /**
* Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool. * Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.
@@ -98,7 +99,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}) })
} }
let currentWallId: string | null = movingWindowNode.parentId let currentHostId: string | null = movingWindowNode.parentId
let dragAnchor: { let dragAnchor: {
wallId: string wallId: string
rawX: number rawX: number
@@ -118,18 +119,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
event: WallEvent event: WallEvent
} | null = null } | null = null
const markWallDirty = (wallId: string | null) => { const markHostDirty = (hostId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
} }
const lastWallDirtyAt = new Map<string, number>() const lastHostDirtyAt = new Map<string, number>()
const markWallDirtyThrottled = (wallId: string | null) => { const markHostDirtyThrottled = (hostId: string | null) => {
if (!wallId) return if (!hostId) return
const now = globalThis.performance?.now?.() ?? Date.now() const now = globalThis.performance?.now?.() ?? Date.now()
const last = lastWallDirtyAt.get(wallId) ?? 0 const last = lastHostDirtyAt.get(hostId) ?? 0
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse. // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
if (now - last > 120) { if (now - last > 120) {
lastWallDirtyAt.set(wallId, now) lastHostDirtyAt.set(hostId, now)
markWallDirty(wallId) markHostDirty(hostId)
} }
} }
@@ -236,7 +237,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
} }
const applyPreview = (target: NonNullable<typeof lastTarget>) => { const applyPreview = (target: NonNullable<typeof lastTarget>) => {
if (currentWallId !== target.wallId) { if (currentHostId !== target.wallId) {
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: [target.clampedX, target.clampedY, 0], position: [target.clampedX, target.clampedY, 0],
rotation: [0, target.itemRotation, 0], rotation: [0, target.itemRotation, 0],
@@ -246,8 +247,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined, roofFace: undefined,
}) })
markWallDirty(currentWallId) markHostDirty(currentHostId)
currentWallId = target.wallId currentHostId = target.wallId
} else { } else {
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId) const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
if (windowMesh) { if (windowMesh) {
@@ -260,7 +261,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
position: [target.clampedX, target.clampedY, 0], position: [target.clampedX, target.clampedY, 0],
rotation: target.itemRotation, rotation: target.itemRotation,
}) })
markWallDirtyThrottled(target.wallId) markHostDirtyThrottled(target.wallId)
updateCursor( updateCursor(
wallLocalToWorld( wallLocalToWorld(
@@ -318,10 +319,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const cloned = structuredClone(movingWindowNode) as any const cloned = structuredClone(movingWindowNode) as any
delete cloned.id delete cloned.id
if (cloned.metadata && typeof cloned.metadata === 'object') { cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
delete cloned.metadata.isNew
delete cloned.metadata.isTransient
}
const node = WindowNode.parse({ const node = WindowNode.parse({
...cloned, ...cloned,
@@ -361,12 +359,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}) })
if (original.parentId && original.parentId !== target.wallId) { if (original.parentId && original.parentId !== target.wallId) {
markWallDirty(original.parentId) markHostDirty(original.parentId)
} }
placedId = movingWindowNode.id placedId = movingWindowNode.id
} }
markWallDirty(target.wallId) markHostDirty(target.wallId)
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -384,10 +382,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
lastTarget = null lastTarget = null
if (isNew) return // No original to restore for duplicates if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall // Move mode: restore to original position while off-wall
if (currentWallId && currentWallId !== original.parentId) { if (currentHostId && currentHostId !== original.parentId) {
markWallDirty(currentWallId) markHostDirty(currentHostId)
} }
currentWallId = original.parentId currentHostId = original.parentId
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: original.position, position: original.position,
rotation: original.rotation, rotation: original.rotation,
@@ -397,7 +395,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markHostDirty(original.parentId)
} }
// ── Roof-segment wall faces ───────────────────────────────────── // ── Roof-segment wall faces ─────────────────────────────────────
@@ -406,64 +404,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
// the gable pediment). This is also the placement path preset tiles // the gable pediment). This is also the placement path preset tiles
// take (`metadata.isNew` clones). // take (`metadata.isNew` clones).
const worldToBuildingLocal = (point: Vector3): [number, number, number] => { const resolveRoofMoveTarget = (event: RoofEvent) =>
const buildingId = useViewer.getState().selection.buildingId resolveRoofWallOpeningTarget({
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined event,
if (buildingObj) buildingObj.worldToLocal(point) width: movingWindowNode.width,
return [point.x, point.y, point.z] height: movingWindowNode.height,
} ignoreId: movingWindowNode.id,
vertical: { kind: 'free', snap: snapToHalf },
})
const resolveRoofMoveTarget = (event: RoofEvent) => { const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const hit = resolveRoofWallHit( const pose = getRoofWallOpeningCursorPose(target, roof)
event.node as RoofNode, if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
event.position,
event.normal,
event.object,
)
if (!hit) return null
// Free vertical placement (0.5m grid like walls); the clamp
// projects the window inside the face profile, sliding it down
// under the gable slopes when needed.
const clamped = clampRectToRoofWallFace(
hit.face,
hit.u,
snapToHalf(hit.v),
movingWindowNode.width,
movingWindowNode.height,
)
if (!clamped) return null
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
// mounts the node inside the live face frame, so it tracks segment
// resizes without any re-anchoring.
const position: [number, number, number] = [clamped.u, clamped.v, 0]
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
movingWindowNode.width,
movingWindowNode.height,
movingWindowNode.id,
)
return { hit, position, valid, roof: event.node as RoofNode }
}
const updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return
segObj.updateWorldMatrix(true, false)
const segLocal = roofFacePointToSegment(
target.hit.segment,
target.hit.face.id,
target.position,
)
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
target.valid,
)
} }
const onRoofHover = (event: RoofEvent) => { const onRoofHover = (event: RoofEvent) => {
@@ -473,33 +425,33 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
if (currentWallId !== target.hit.segment.id) { if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: target.position, position: target.position,
rotation: [0, 0, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
parentId: target.hit.segment.id, parentId: target.segment.id,
wallId: undefined, wallId: undefined,
roofSegmentId: target.hit.segment.id, roofSegmentId: target.segment.id,
roofFace: target.hit.face.id, roofFace: target.face.id,
}) })
markWallDirty(currentWallId) markHostDirty(currentHostId)
currentWallId = target.hit.segment.id currentHostId = target.segment.id
} else { } else {
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: target.position, position: target.position,
rotation: [0, 0, 0], rotation: [0, 0, 0],
roofFace: target.hit.face.id, roofFace: target.face.id,
}) })
} }
updateRoofCursor(target) updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation() event.stopPropagation()
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event) const target = resolveRoofMoveTarget(event)
if (!target?.valid) return if (!target?.valid) return
const segmentId = target.hit.segment.id const segmentId = target.segment.id
let placedId: string let placedId: string
@@ -509,10 +461,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const cloned = structuredClone(movingWindowNode) as any const cloned = structuredClone(movingWindowNode) as any
delete cloned.id delete cloned.id
if (cloned.metadata && typeof cloned.metadata === 'object') { cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
delete cloned.metadata.isNew
delete cloned.metadata.isTransient
}
const node = WindowNode.parse({ const node = WindowNode.parse({
...cloned, ...cloned,
@@ -521,7 +470,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
side: 'front', side: 'front',
wallId: undefined, wallId: undefined,
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.hit.face.id, roofFace: target.face.id,
parentId: segmentId, parentId: segmentId,
}) })
useScene.getState().createNode(node, segmentId as AnyNodeId) useScene.getState().createNode(node, segmentId as AnyNodeId)
@@ -546,17 +495,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: segmentId, parentId: segmentId,
wallId: undefined, wallId: undefined,
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.hit.face.id, roofFace: target.face.id,
metadata: {}, metadata: {},
}) })
if (original.parentId && original.parentId !== segmentId) { if (original.parentId && original.parentId !== segmentId) {
markWallDirty(original.parentId) markHostDirty(original.parentId)
} }
placedId = movingWindowNode.id placedId = movingWindowNode.id
} }
markWallDirty(segmentId) markHostDirty(segmentId)
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -573,10 +522,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
dragAnchor = null dragAnchor = null
lastTarget = null lastTarget = null
if (isNew) return if (isNew) return
if (currentWallId && currentWallId !== original.parentId) { if (currentHostId && currentHostId !== original.parentId) {
markWallDirty(currentWallId) markHostDirty(currentHostId)
} }
currentWallId = original.parentId currentHostId = original.parentId
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: original.position, position: original.position,
rotation: original.rotation, rotation: original.rotation,
@@ -586,14 +535,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace, roofFace: original.roofFace,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markHostDirty(original.parentId)
} }
const onCancel = () => { const onCancel = () => {
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
if (isNew) { if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id) useScene.getState().deleteNode(movingWindowNode.id)
if (currentWallId) markWallDirty(currentWallId) if (currentHostId) markHostDirty(currentHostId)
} else { } else {
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: original.position, position: original.position,
@@ -605,7 +554,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markHostDirty(original.parentId)
} }
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
hideCursor() hideCursor()
@@ -631,7 +580,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
if (currentMeta?.isTransient) { if (currentMeta?.isTransient) {
if (isNew) { if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id) useScene.getState().deleteNode(movingWindowNode.id)
if (currentWallId) markWallDirty(currentWallId) if (currentHostId) markHostDirty(currentHostId)
} else { } else {
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: original.position, position: original.position,
@@ -643,7 +592,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofFace: original.roofFace, roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markHostDirty(original.parentId)
} }
} }
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
+34 -81
View File
@@ -1,12 +1,10 @@
import { import {
type AnyNodeId, type AnyNodeId,
clampRectToRoofWallFace,
collectAlignmentAnchors, collectAlignmentAnchors,
emitter, emitter,
isCurvedWall, isCurvedWall,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
roofFacePointToSegment,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -18,17 +16,20 @@ import {
calculateItemRotation, calculateItemRotation,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace, isValidWallSideFace,
resolveRoofWallHit,
snapToHalf, snapToHalf,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
resolveRoofWallOpeningTarget,
type RoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -40,7 +41,6 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false, depthWrite: false,
}) })
const roofCursorPoint = new Vector3()
/** /**
* Window tool — places WindowNodes on walls and on roof-segment wall * Window tool — places WindowNodes on walls and on roof-segment wall
@@ -68,8 +68,8 @@ const WindowTool: React.FC = () => {
wallEvent.node.end, wallEvent.node.end,
) )
const markWallDirty = (wallId: string) => { const markHostDirty = (hostId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
} }
const destroyDraft = () => { const destroyDraft = () => {
@@ -78,7 +78,7 @@ const WindowTool: React.FC = () => {
useScene.getState().deleteNode(draftRef.current.id) useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null draftRef.current = null
// Rebuild wall so it removes the cutout from the deleted draft // Rebuild wall so it removes the cutout from the deleted draft
if (wallId) markWallDirty(wallId) if (wallId) markHostDirty(wallId)
} }
const hideCursor = () => { const hideCursor = () => {
@@ -225,7 +225,7 @@ const WindowTool: React.FC = () => {
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
side, side,
}) })
markWallDirty(event.node.id) markHostDirty(event.node.id)
} else { } else {
useScene.getState().updateNode(draftRef.current.id, { useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0], position: [clampedX, clampedY, 0],
@@ -362,65 +362,18 @@ const WindowTool: React.FC = () => {
// so a window can sit anywhere inside the face profile — including // so a window can sit anywhere inside the face profile — including
// the gable pediment triangle. // the gable pediment triangle.
const worldToBuildingLocal = (point: Vector3): [number, number, number] => { const resolveRoofTarget = (event: RoofEvent) =>
// The tool's cursor group renders in the building's local frame — resolveRoofWallOpeningTarget({
// same conversion as the roof accessory tools (e.g. SkylightTool). event,
const buildingId = useViewer.getState().selection.buildingId width: draftRef.current?.width ?? 1.5,
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined height: draftRef.current?.height ?? 1.5,
if (buildingObj) buildingObj.worldToLocal(point) ignoreId: draftRef.current?.id,
return [point.x, point.y, point.z] vertical: { kind: 'free', snap: snapToHalf },
} })
const resolveRoofTarget = (event: RoofEvent) => { const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const hit = resolveRoofWallHit( const pose = getRoofWallOpeningCursorPose(target, roof)
event.node as RoofNode, if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
event.position,
event.normal,
event.object,
)
if (!hit) return null
const width = draftRef.current?.width ?? 1.5
const height = draftRef.current?.height ?? 1.5
// Free vertical placement (snapped to the 0.5m grid like walls);
// the clamp projects the window inside the face profile, sliding
// it down under the gable slopes when needed.
const clamped = clampRectToRoofWallFace(hit.face, hit.u, snapToHalf(hit.v), width, height)
if (!clamped) return null
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
// mounts the node inside the live face frame, so it tracks segment
// resizes without any re-anchoring.
const position: [number, number, number] = [clamped.u, clamped.v, 0]
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
width,
height,
draftRef.current?.id,
)
return { hit, position, valid }
}
const updateRoofCursor = (
target: NonNullable<ReturnType<typeof resolveRoofTarget>>,
roof: RoofNode,
) => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return
segObj.updateWorldMatrix(true, false)
const segLocal = roofFacePointToSegment(
target.hit.segment,
target.hit.face.id,
target.position,
)
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
target.valid,
)
} }
const onRoofHover = (event: RoofEvent) => { const onRoofHover = (event: RoofEvent) => {
@@ -434,26 +387,26 @@ const WindowTool: React.FC = () => {
} }
return return
} }
const { hit, position } = target const { segment, face, position } = target
if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft() if (draftRef.current && draftRef.current.parentId !== segment.id) destroyDraft()
if (draftRef.current) { if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, { useScene.getState().updateNode(draftRef.current.id, {
position, position,
rotation: [0, 0, 0], rotation: [0, 0, 0],
roofFace: hit.face.id, roofFace: face.id,
}) })
} else { } else {
const node = WindowNode.parse({ const node = WindowNode.parse({
position, position,
rotation: [0, 0, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
roofSegmentId: hit.segment.id, roofSegmentId: segment.id,
roofFace: hit.face.id, roofFace: face.id,
parentId: hit.segment.id, parentId: segment.id,
metadata: { isTransient: true }, metadata: { isTransient: true },
}) })
useScene.getState().createNode(node, hit.segment.id as AnyNodeId) useScene.getState().createNode(node, segment.id as AnyNodeId)
draftRef.current = node draftRef.current = node
} }
updateRoofCursor(target, event.node as RoofNode) updateRoofCursor(target, event.node as RoofNode)
@@ -464,7 +417,7 @@ const WindowTool: React.FC = () => {
if (!draftRef.current?.roofSegmentId) return if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event) const target = resolveRoofTarget(event)
if (!target?.valid) return if (!target?.valid) return
const { hit, position } = target const { segment, face, position } = target
const draft = draftRef.current const draft = draftRef.current
draftRef.current = null draftRef.current = null
@@ -482,9 +435,9 @@ const WindowTool: React.FC = () => {
position, position,
rotation: [0, 0, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
roofSegmentId: hit.segment.id, roofSegmentId: segment.id,
roofFace: hit.face.id, roofFace: face.id,
parentId: hit.segment.id, parentId: segment.id,
width: draft.width, width: draft.width,
height: draft.height, height: draft.height,
windowType: draft.windowType, windowType: draft.windowType,
@@ -503,10 +456,10 @@ const WindowTool: React.FC = () => {
sillThickness: draft.sillThickness, sillThickness: draft.sillThickness,
}) })
useScene.getState().createNode(node, hit.segment.id as AnyNodeId) useScene.getState().createNode(node, segment.id as AnyNodeId)
// Rebuild the segment (and the merged roof) so the wall brush // Rebuild the segment (and the merged roof) so the wall brush
// picks up the new opening cut. // picks up the new opening cut.
useScene.getState().dirtyNodes.add(hit.segment.id as AnyNodeId) useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build') triggerSFX('sfx:structure-build')
@@ -110,12 +110,12 @@ export const RoofSystem = () => {
// previous cut shape (stale CSG) once the user exits segment // previous cut shape (stale CSG) once the user exits segment
// edit mode. Registry-driven so the viewer stays kind-agnostic. // edit mode. Registry-driven so the viewer stays kind-agnostic.
const def = nodeRegistry.get(node.type) const def = nodeRegistry.get(node.type)
// Kinds with `cascadesViaHostSegment` (door / window) reach the roof // Kinds with `dirtyHandledByOwnSystem` (door / window) reach the roof
// through their own geometry system's parentId cascade instead — // through their own geometry system's parentId cascade instead —
// their dirty marks belong to that system, not to this loop. // their dirty marks belong to that system, not to this loop.
if ( if (
def?.capabilities?.roofAccessory && def?.capabilities?.roofAccessory &&
!def.capabilities.roofAccessory.cascadesViaHostSegment !def.capabilities.roofAccessory.dirtyHandledByOwnSystem
) { ) {
const segId = (node as { roofSegmentId?: string }).roofSegmentId const segId = (node as { roofSegmentId?: string }).roofSegmentId
const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined