diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 80e5144b..90407613 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,6 +1,6 @@ 'use client' -import { getRoofAccessoryKinds, nodeRegistry } from '@pascal-app/core' +import { nodeRegistry } from '@pascal-app/core' import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' import Image from 'next/image' 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 * the community editor these aren't DB presets — each is a registry kind with - * `capabilities.roofAccessory`, discovered via `getRoofAccessoryKinds()` 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 + * `capabilities.roofAccessory`, enumerated from the registry at render time + * (it is populated by the app bootstrap — a module-scope const would race it) + * 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. */ 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 // app bootstrap, so enumerating earlier would race it and see no kinds. - const roofFeatures = useMemo( - () => - getRoofAccessoryKinds() - // Door / window declare `roofAccessory` for the wall-face cut but - // already have their own Build tiles — listing them here too - // would duplicate the entry under Roof → Features. - .filter((kind) => !nodeRegistry.get(kind)?.capabilities?.wallOpeningPlacement) - .map((kind) => { - const icon = nodeRegistry.get(kind)?.presentation?.icon - return { - kind, - label: nodeRegistry.get(kind)?.presentation?.label ?? kind, - iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, - } - }), - [], - ) + const roofFeatures = useMemo(() => { + const features: RoofFeature[] = [] + for (const [kind, def] of nodeRegistry.entries()) { + if (def.capabilities.roofAccessory === undefined) continue + // Door / window declare `roofAccessory` for the wall-face cut but + // already have their own Build tiles — listing them here too + // would duplicate the entry under Roof → Features. + if (def.capabilities.wallOpeningPlacement) continue + const icon = def.presentation?.icon + features.push({ + kind, + label: def.presentation?.label ?? kind, + iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, + }) + } + return features + }, []) const isTypeActive = (type: BuildType) => type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 94bb862a..8dcfc58f 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -17,7 +17,6 @@ export type { export { discoverPlugins, getHostRefFields, - getRoofAccessoryKinds, getSelectableKinds, isDrawnViaTool, isDrawnViaToolKind, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 658636f3..051176fc 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -114,28 +114,6 @@ export function isRegistrySelectable(kind: string): boolean { 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 * `FloorplanRegistryLayer` to discover building-scoped kinds (e.g. diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 0ccf97a9..c168871f 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1214,14 +1214,14 @@ export type RoofAccessoryConfig = { */ cutScope?: 'all' | 'wall' /** - * Set when the kind runs its own dirty-driven geometry system that - * already cascades to the host segment (door / window via the - * DoorSystem / WindowSystem `parentId` cascade). The roof-merge loop - * must then leave the kind's dirty marks alone — consuming them here - * would starve that system whenever it defers a rebuild (mesh not - * mounted yet, per-frame rebuild budget exhausted). + * The kind's own dirty-driven geometry system consumes its dirty + * marks (door / window via DoorSystem / WindowSystem, which already + * cascade to the host segment through `parentId`). The roof-merge + * loop must then leave those marks alone — consuming them would + * starve that system whenever it defers a rebuild (mesh not mounted + * yet, per-frame rebuild budget exhausted). */ - cascadesViaHostSegment?: boolean + dirtyHandledByOwnSystem?: boolean } /** diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 2e2894b3..a69e91b5 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -76,6 +76,13 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { | undefined } + // Remap roofSegmentId (doors/windows/items hosted on roof wall faces) + if ('roofSegmentId' in clonedNode && typeof clonedNode.roofSegmentId === 'string') { + ;(clonedNode as Record).roofSegmentId = idMap.get( + clonedNode.roofSegmentId, + ) as string | undefined + } + clonedNodes[newId] = clonedNode } @@ -220,6 +227,12 @@ export function cloneLevelSubtree( ;(cloned as Record).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).roofSegmentId = + idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId + } + clonedNodes.push(cloned) } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 7ffd7f99..a8538131 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -55,6 +55,7 @@ import { NO_RAYCAST, } from './handles/handle-arrow' import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' + // Pooled scratch for the handle rig's world-relative pose mapping. const _rigRelative = new Matrix4() const _rigScratchScale = new Vector3() diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 67da8030..4a6f4a7d 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -363,7 +363,7 @@ type RoofWallTarget = { segment: RoofSegmentNode faceId: RoofWallFaceId faceYaw: number - /** Stored node position: segment-local, y = bottom edge. */ + /** Stored node position: FACE-LOCAL, y = bottom edge. */ position: [number, number, number] /** Face-coord center of the placed rect (for the overlap guard). */ centerU: number diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 7b02fc34..e47f2665 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1062,9 +1062,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // push (z = thickness/2 off the face frame's mid-plane) so the // drag preview doesn't sink into the wall until commit. if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) { - const segment = useScene.getState().nodes[ - placementState.current.roofSegmentId as AnyNodeId - ] + const segment = + useScene.getState().nodes[placementState.current.roofSegmentId as AnyNodeId] if (segment?.type === 'roof-segment') { mesh.position.z = (segment.wallThickness ?? 0.1) / 2 } diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index ae51da31..2c86b92c 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -160,14 +160,14 @@ export const doorDefinition: NodeDefinition = { wallOpeningPlacement: true, // Doors also host on roof-segment wall faces (base walls under 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 // already cascades to the host via parentId). roofAccessory: { buildCut: (node, hostSegment) => buildRoofWallOpeningCut(node as DoorNodeType, hostSegment as RoofSegmentNode), cutScope: 'wall', - cascadesViaHostSegment: true, + dirtyHandledByOwnSystem: true, }, // `wallId` / `roofSegmentId` tie the door to its host and are // re-derived from the surface under the cursor when a preset is diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index c28c029a..4a29c564 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -1,13 +1,11 @@ import { type AnyNodeId, - clampRectToRoofWallFace, collectAlignmentAnchors, DoorNode, emitter, isCurvedWall, type RoofEvent, type RoofNode, - roofFacePointToSegment, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -19,9 +17,7 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, - hasRoofFaceChildOverlap, isValidWallSideFace, - resolveRoofWallHit, stripPlacementMetadataFlags, triggerSFX, useAlignmentGuides, @@ -29,8 +25,13 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' 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 { + getRoofWallOpeningCursorPose, + resolveRoofWallOpeningTarget, + type RoofWallOpeningTarget, +} from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -41,7 +42,6 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) -const roofCursorPoint = new Vector3() const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const cursorGroupRef = useRef(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 lastTarget: { wallNode: WallEvent['node'] @@ -93,18 +93,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => event: WallEvent } | null = null - const markWallDirty = (wallId: string | null) => { - if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) + const markHostDirty = (hostId: string | null) => { + if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId) } - const lastWallDirtyAt = new Map() - const markWallDirtyThrottled = (wallId: string | null) => { - if (!wallId) return + const lastHostDirtyAt = new Map() + const markHostDirtyThrottled = (hostId: string | null) => { + if (!hostId) return 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. if (now - last > 120) { - lastWallDirtyAt.set(wallId, now) - markWallDirty(wallId) + lastHostDirtyAt.set(hostId, now) + markHostDirty(hostId) } } @@ -213,7 +213,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const applyPreview = (target: NonNullable) => { - if (currentWallId !== target.wallId) { + if (currentHostId !== target.wallId) { useScene.getState().updateNode(movingDoorNode.id, { position: [target.clampedX, target.clampedY, 0], rotation: [0, target.itemRotation, 0], @@ -223,8 +223,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: undefined, roofFace: undefined, }) - markWallDirty(currentWallId) - currentWallId = target.wallId + markHostDirty(currentHostId) + currentHostId = target.wallId } else { const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId) if (doorMesh) { @@ -237,7 +237,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => position: [target.clampedX, target.clampedY, 0], rotation: target.itemRotation, }) - markWallDirtyThrottled(target.wallId) + markHostDirtyThrottled(target.wallId) updateCursor( wallLocalToWorld( @@ -328,12 +328,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => }) if (original.parentId && original.parentId !== target.wallId) { - markWallDirty(original.parentId) + markHostDirty(original.parentId) } placedId = movingDoorNode.id } - markWallDirty(target.wallId) + markHostDirty(target.wallId) useLiveTransforms.getState().clear(movingDoorNode.id) useScene.temporal.getState().pause() @@ -350,10 +350,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => dragAnchor = null lastTarget = null if (isNew) return - if (currentWallId && currentWallId !== original.parentId) { - markWallDirty(currentWallId) + if (currentHostId && currentHostId !== original.parentId) { + markHostDirty(currentHostId) } - currentWallId = original.parentId + currentHostId = original.parentId useScene.getState().updateNode(movingDoorNode.id, { position: original.position, rotation: original.rotation, @@ -363,7 +363,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, }) - if (original.parentId) markWallDirty(original.parentId) + if (original.parentId) markHostDirty(original.parentId) } // ── 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 // placement path preset tiles take (`metadata.isNew` clones). - const worldToBuildingLocal = (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] - } + const resolveRoofMoveTarget = (event: RoofEvent) => + resolveRoofWallOpeningTarget({ + event, + width: movingDoorNode.width, + height: movingDoorNode.height, + ignoreId: movingDoorNode.id, + vertical: { kind: 'bottom-locked' }, + }) - const resolveRoofMoveTarget = (event: RoofEvent) => { - const hit = resolveRoofWallHit( - event.node as RoofNode, - 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>) => { - 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 updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { + const pose = getRoofWallOpeningCursorPose(target, roof) + if (pose) updateCursor(pose.position, pose.rotationY, target.valid) } const onRoofHover = (event: RoofEvent) => { @@ -437,33 +392,33 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => dragAnchor = null lastTarget = null useLiveTransforms.getState().clear(movingDoorNode.id) - if (currentWallId !== target.hit.segment.id) { + if (currentHostId !== target.segment.id) { useScene.getState().updateNode(movingDoorNode.id, { position: target.position, rotation: [0, 0, 0], side: 'front', - parentId: target.hit.segment.id, + parentId: target.segment.id, wallId: undefined, - roofSegmentId: target.hit.segment.id, - roofFace: target.hit.face.id, + roofSegmentId: target.segment.id, + roofFace: target.face.id, }) - markWallDirty(currentWallId) - currentWallId = target.hit.segment.id + markHostDirty(currentHostId) + currentHostId = target.segment.id } else { useScene.getState().updateNode(movingDoorNode.id, { position: target.position, rotation: [0, 0, 0], - roofFace: target.hit.face.id, + roofFace: target.face.id, }) } - updateRoofCursor(target) + updateRoofCursor(target, event.node as RoofNode) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { const target = resolveRoofMoveTarget(event) if (!target?.valid) return - const segmentId = target.hit.segment.id + const segmentId = target.segment.id let placedId: string @@ -481,7 +436,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: 'front', wallId: undefined, roofSegmentId: segmentId, - roofFace: target.hit.face.id, + roofFace: target.face.id, parentId: segmentId, }) useScene.getState().createNode(node, segmentId as AnyNodeId) @@ -506,17 +461,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: segmentId, wallId: undefined, roofSegmentId: segmentId, - roofFace: target.hit.face.id, + roofFace: target.face.id, metadata: {}, }) if (original.parentId && original.parentId !== segmentId) { - markWallDirty(original.parentId) + markHostDirty(original.parentId) } placedId = movingDoorNode.id } - markWallDirty(segmentId) + markHostDirty(segmentId) useLiveTransforms.getState().clear(movingDoorNode.id) useScene.temporal.getState().pause() @@ -533,10 +488,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => dragAnchor = null lastTarget = null if (isNew) return - if (currentWallId && currentWallId !== original.parentId) { - markWallDirty(currentWallId) + if (currentHostId && currentHostId !== original.parentId) { + markHostDirty(currentHostId) } - currentWallId = original.parentId + currentHostId = original.parentId useScene.getState().updateNode(movingDoorNode.id, { position: original.position, rotation: original.rotation, @@ -546,14 +501,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, }) - if (original.parentId) markWallDirty(original.parentId) + if (original.parentId) markHostDirty(original.parentId) } const onCancel = () => { useLiveTransforms.getState().clear(movingDoorNode.id) if (isNew) { useScene.getState().deleteNode(movingDoorNode.id) - if (currentWallId) markWallDirty(currentWallId) + if (currentHostId) markHostDirty(currentHostId) } else { useScene.getState().updateNode(movingDoorNode.id, { position: original.position, @@ -565,7 +520,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofFace: original.roofFace, metadata: original.metadata, }) - if (original.parentId) markWallDirty(original.parentId) + if (original.parentId) markHostDirty(original.parentId) } useScene.temporal.getState().resume() hideCursor() @@ -590,7 +545,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => if (currentMeta?.isTransient) { if (isNew) { useScene.getState().deleteNode(movingDoorNode.id) - if (currentWallId) markWallDirty(currentWallId) + if (currentHostId) markHostDirty(currentHostId) } else { useScene.getState().updateNode(movingDoorNode.id, { position: original.position, @@ -602,7 +557,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofFace: original.roofFace, metadata: original.metadata, }) - if (original.parentId) markWallDirty(original.parentId) + if (original.parentId) markHostDirty(original.parentId) } } useLiveTransforms.getState().clear(movingDoorNode.id) diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 94ab2a1f..be0421b9 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -1,13 +1,11 @@ import { type AnyNodeId, - clampRectToRoofWallFace, collectAlignmentAnchors, DoorNode, emitter, isCurvedWall, type RoofEvent, type RoofNode, - roofFacePointToSegment, sceneRegistry, spatialGridManager, useScene, @@ -18,16 +16,19 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, - hasRoofFaceChildOverlap, isValidWallSideFace, - resolveRoofWallHit, triggerSFX, useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' 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 { + getRoofWallOpeningCursorPose, + resolveRoofWallOpeningTarget, + type RoofWallOpeningTarget, +} from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -38,7 +39,6 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) -const roofCursorPoint = new Vector3() /** * Door tool — places DoorNodes on walls and on roof-segment wall faces @@ -66,8 +66,8 @@ const DoorTool: React.FC = () => { wallEvent.node.end, ) - const markWallDirty = (wallId: string) => { - useScene.getState().dirtyNodes.add(wallId as AnyNodeId) + const markHostDirty = (hostId: string) => { + useScene.getState().dirtyNodes.add(hostId as AnyNodeId) } const destroyDraft = () => { @@ -75,7 +75,7 @@ const DoorTool: React.FC = () => { const wallId = draftRef.current.parentId useScene.getState().deleteNode(draftRef.current.id) draftRef.current = null - if (wallId) markWallDirty(wallId) + if (wallId) markHostDirty(wallId) } const hideCursor = () => { @@ -217,7 +217,7 @@ const DoorTool: React.FC = () => { rotation: [0, itemRotation, 0], side, }) - markWallDirty(event.node.id) + markHostDirty(event.node.id) } else { useScene.getState().updateNode(draftRef.current.id, { position: [clampedX, clampedY, 0], @@ -352,65 +352,18 @@ const DoorTool: React.FC = () => { // The merged roof mesh emits `roof:*`; hits are resolved against the // segments' vertical wall faces (base walls + coplanar gable ends). - const worldToBuildingLocal = (point: Vector3): [number, number, number] => { - // The tool's cursor group renders in the building's local frame — - // same conversion as the roof accessory tools (e.g. SkylightTool). - 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] - } - - 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, + const resolveRoofTarget = (event: RoofEvent) => + resolveRoofWallOpeningTarget({ + event, + width: draftRef.current?.width ?? 0.9, + height: draftRef.current?.height ?? 2.1, + ignoreId: draftRef.current?.id, + vertical: { kind: 'bottom-locked' }, }) - 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>, - 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 updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { + const pose = getRoofWallOpeningCursorPose(target, roof) + if (pose) updateCursor(pose.position, pose.rotationY, target.valid) } const onRoofHover = (event: RoofEvent) => { @@ -424,26 +377,26 @@ const DoorTool: React.FC = () => { } 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) { useScene.getState().updateNode(draftRef.current.id, { position, rotation: [0, 0, 0], - roofFace: hit.face.id, + roofFace: face.id, }) } else { const node = DoorNode.parse({ position, rotation: [0, 0, 0], side: 'front', - roofSegmentId: hit.segment.id, - roofFace: hit.face.id, - parentId: hit.segment.id, + roofSegmentId: segment.id, + roofFace: face.id, + parentId: segment.id, metadata: { isTransient: true }, }) - useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + useScene.getState().createNode(node, segment.id as AnyNodeId) draftRef.current = node } updateRoofCursor(target, event.node as RoofNode) @@ -454,7 +407,7 @@ const DoorTool: React.FC = () => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) if (!target?.valid) return - const { hit, position } = target + const { segment, face, position } = target const draft = draftRef.current draftRef.current = null @@ -472,9 +425,9 @@ const DoorTool: React.FC = () => { position, rotation: [0, 0, 0], side: 'front', - roofSegmentId: hit.segment.id, - roofFace: hit.face.id, - parentId: hit.segment.id, + roofSegmentId: segment.id, + roofFace: face.id, + parentId: segment.id, width: draft.width, height: draft.height, doorCategory: draft.doorCategory, @@ -499,10 +452,10 @@ const DoorTool: React.FC = () => { 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 // 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] }) useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') diff --git a/packages/nodes/src/shared/roof-wall-opening-placement.ts b/packages/nodes/src/shared/roof-wall-opening-placement.ts new file mode 100644 index 00000000..603909f8 --- /dev/null +++ b/packages/nodes/src/shared/roof-wall-opening-placement.ts @@ -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, + } +} diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 83bbd778..1ef18efb 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -152,12 +152,12 @@ export const windowDefinition: NodeDefinition = { wallOpeningPlacement: true, // Windows also host on roof-segment wall faces (base walls under the // roof, gable ends) — same wiring as door; see the door capability - // for why `cascadesViaHostSegment` is required. + // for why `dirtyHandledByOwnSystem` is required. roofAccessory: { buildCut: (node, hostSegment) => buildRoofWallOpeningCut(node as WindowNodeType, hostSegment as RoofSegmentNode), cutScope: 'wall', - cascadesViaHostSegment: true, + dirtyHandledByOwnSystem: true, }, // `wallId` / `roofSegmentId` are re-derived from the surface under // the cursor at preset placement time — see door for the pattern. diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index ed776e76..1f8cb9fe 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,12 +1,10 @@ import { type AnyNodeId, - clampRectToRoofWallFace, collectAlignmentAnchors, emitter, isCurvedWall, type RoofEvent, type RoofNode, - roofFacePointToSegment, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -19,18 +17,22 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, - hasRoofFaceChildOverlap, isValidWallSideFace, - resolveRoofWallHit, snapToHalf, + stripPlacementMetadataFlags, triggerSFX, useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' 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 { + getRoofWallOpeningCursorPose, + resolveRoofWallOpeningTarget, + type RoofWallOpeningTarget, +} from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -41,7 +43,6 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) -const roofCursorPoint = new Vector3() /** * 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: { wallId: string rawX: number @@ -118,18 +119,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode event: WallEvent } | null = null - const markWallDirty = (wallId: string | null) => { - if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) + const markHostDirty = (hostId: string | null) => { + if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId) } - const lastWallDirtyAt = new Map() - const markWallDirtyThrottled = (wallId: string | null) => { - if (!wallId) return + const lastHostDirtyAt = new Map() + const markHostDirtyThrottled = (hostId: string | null) => { + if (!hostId) return 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. if (now - last > 120) { - lastWallDirtyAt.set(wallId, now) - markWallDirty(wallId) + lastHostDirtyAt.set(hostId, now) + markHostDirty(hostId) } } @@ -236,7 +237,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const applyPreview = (target: NonNullable) => { - if (currentWallId !== target.wallId) { + if (currentHostId !== target.wallId) { useScene.getState().updateNode(movingWindowNode.id, { position: [target.clampedX, target.clampedY, 0], rotation: [0, target.itemRotation, 0], @@ -246,8 +247,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: undefined, roofFace: undefined, }) - markWallDirty(currentWallId) - currentWallId = target.wallId + markHostDirty(currentHostId) + currentHostId = target.wallId } else { const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId) if (windowMesh) { @@ -260,7 +261,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode position: [target.clampedX, target.clampedY, 0], rotation: target.itemRotation, }) - markWallDirtyThrottled(target.wallId) + markHostDirtyThrottled(target.wallId) updateCursor( wallLocalToWorld( @@ -318,10 +319,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const cloned = structuredClone(movingWindowNode) as any delete cloned.id - if (cloned.metadata && typeof cloned.metadata === 'object') { - delete cloned.metadata.isNew - delete cloned.metadata.isTransient - } + cloned.metadata = stripPlacementMetadataFlags(cloned.metadata) const node = WindowNode.parse({ ...cloned, @@ -361,12 +359,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) if (original.parentId && original.parentId !== target.wallId) { - markWallDirty(original.parentId) + markHostDirty(original.parentId) } placedId = movingWindowNode.id } - markWallDirty(target.wallId) + markHostDirty(target.wallId) useLiveTransforms.getState().clear(movingWindowNode.id) useScene.temporal.getState().pause() @@ -384,10 +382,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode lastTarget = null if (isNew) return // No original to restore for duplicates // Move mode: restore to original position while off-wall - if (currentWallId && currentWallId !== original.parentId) { - markWallDirty(currentWallId) + if (currentHostId && currentHostId !== original.parentId) { + markHostDirty(currentHostId) } - currentWallId = original.parentId + currentHostId = original.parentId useScene.getState().updateNode(movingWindowNode.id, { position: original.position, rotation: original.rotation, @@ -397,7 +395,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, }) - if (original.parentId) markWallDirty(original.parentId) + if (original.parentId) markHostDirty(original.parentId) } // ── 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 // take (`metadata.isNew` clones). - const worldToBuildingLocal = (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] - } + const resolveRoofMoveTarget = (event: RoofEvent) => + resolveRoofWallOpeningTarget({ + event, + width: movingWindowNode.width, + height: movingWindowNode.height, + ignoreId: movingWindowNode.id, + vertical: { kind: 'free', snap: snapToHalf }, + }) - const resolveRoofMoveTarget = (event: RoofEvent) => { - const hit = resolveRoofWallHit( - event.node as RoofNode, - 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>) => { - 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 updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { + const pose = getRoofWallOpeningCursorPose(target, roof) + if (pose) updateCursor(pose.position, pose.rotationY, target.valid) } const onRoofHover = (event: RoofEvent) => { @@ -473,33 +425,33 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode dragAnchor = null lastTarget = null useLiveTransforms.getState().clear(movingWindowNode.id) - if (currentWallId !== target.hit.segment.id) { + if (currentHostId !== target.segment.id) { useScene.getState().updateNode(movingWindowNode.id, { position: target.position, rotation: [0, 0, 0], side: 'front', - parentId: target.hit.segment.id, + parentId: target.segment.id, wallId: undefined, - roofSegmentId: target.hit.segment.id, - roofFace: target.hit.face.id, + roofSegmentId: target.segment.id, + roofFace: target.face.id, }) - markWallDirty(currentWallId) - currentWallId = target.hit.segment.id + markHostDirty(currentHostId) + currentHostId = target.segment.id } else { useScene.getState().updateNode(movingWindowNode.id, { position: target.position, rotation: [0, 0, 0], - roofFace: target.hit.face.id, + roofFace: target.face.id, }) } - updateRoofCursor(target) + updateRoofCursor(target, event.node as RoofNode) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { const target = resolveRoofMoveTarget(event) if (!target?.valid) return - const segmentId = target.hit.segment.id + const segmentId = target.segment.id let placedId: string @@ -509,10 +461,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const cloned = structuredClone(movingWindowNode) as any delete cloned.id - if (cloned.metadata && typeof cloned.metadata === 'object') { - delete cloned.metadata.isNew - delete cloned.metadata.isTransient - } + cloned.metadata = stripPlacementMetadataFlags(cloned.metadata) const node = WindowNode.parse({ ...cloned, @@ -521,7 +470,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: 'front', wallId: undefined, roofSegmentId: segmentId, - roofFace: target.hit.face.id, + roofFace: target.face.id, parentId: segmentId, }) useScene.getState().createNode(node, segmentId as AnyNodeId) @@ -546,17 +495,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: segmentId, wallId: undefined, roofSegmentId: segmentId, - roofFace: target.hit.face.id, + roofFace: target.face.id, metadata: {}, }) if (original.parentId && original.parentId !== segmentId) { - markWallDirty(original.parentId) + markHostDirty(original.parentId) } placedId = movingWindowNode.id } - markWallDirty(segmentId) + markHostDirty(segmentId) useLiveTransforms.getState().clear(movingWindowNode.id) useScene.temporal.getState().pause() @@ -573,10 +522,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode dragAnchor = null lastTarget = null if (isNew) return - if (currentWallId && currentWallId !== original.parentId) { - markWallDirty(currentWallId) + if (currentHostId && currentHostId !== original.parentId) { + markHostDirty(currentHostId) } - currentWallId = original.parentId + currentHostId = original.parentId useScene.getState().updateNode(movingWindowNode.id, { position: original.position, rotation: original.rotation, @@ -586,14 +535,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, }) - if (original.parentId) markWallDirty(original.parentId) + if (original.parentId) markHostDirty(original.parentId) } const onCancel = () => { useLiveTransforms.getState().clear(movingWindowNode.id) if (isNew) { useScene.getState().deleteNode(movingWindowNode.id) - if (currentWallId) markWallDirty(currentWallId) + if (currentHostId) markHostDirty(currentHostId) } else { useScene.getState().updateNode(movingWindowNode.id, { position: original.position, @@ -605,7 +554,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofFace: original.roofFace, metadata: original.metadata, }) - if (original.parentId) markWallDirty(original.parentId) + if (original.parentId) markHostDirty(original.parentId) } useScene.temporal.getState().resume() hideCursor() @@ -631,7 +580,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode if (currentMeta?.isTransient) { if (isNew) { useScene.getState().deleteNode(movingWindowNode.id) - if (currentWallId) markWallDirty(currentWallId) + if (currentHostId) markHostDirty(currentHostId) } else { useScene.getState().updateNode(movingWindowNode.id, { position: original.position, @@ -643,7 +592,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofFace: original.roofFace, metadata: original.metadata, }) - if (original.parentId) markWallDirty(original.parentId) + if (original.parentId) markHostDirty(original.parentId) } } useLiveTransforms.getState().clear(movingWindowNode.id) diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 124a9dcc..84d00ebc 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -1,12 +1,10 @@ import { type AnyNodeId, - clampRectToRoofWallFace, collectAlignmentAnchors, emitter, isCurvedWall, type RoofEvent, type RoofNode, - roofFacePointToSegment, sceneRegistry, spatialGridManager, useScene, @@ -18,17 +16,20 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, - hasRoofFaceChildOverlap, isValidWallSideFace, - resolveRoofWallHit, snapToHalf, triggerSFX, useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' 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 { + getRoofWallOpeningCursorPose, + resolveRoofWallOpeningTarget, + type RoofWallOpeningTarget, +} from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -40,7 +41,6 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) -const roofCursorPoint = new Vector3() /** * Window tool — places WindowNodes on walls and on roof-segment wall @@ -68,8 +68,8 @@ const WindowTool: React.FC = () => { wallEvent.node.end, ) - const markWallDirty = (wallId: string) => { - useScene.getState().dirtyNodes.add(wallId as AnyNodeId) + const markHostDirty = (hostId: string) => { + useScene.getState().dirtyNodes.add(hostId as AnyNodeId) } const destroyDraft = () => { @@ -78,7 +78,7 @@ const WindowTool: React.FC = () => { useScene.getState().deleteNode(draftRef.current.id) draftRef.current = null // Rebuild wall so it removes the cutout from the deleted draft - if (wallId) markWallDirty(wallId) + if (wallId) markHostDirty(wallId) } const hideCursor = () => { @@ -225,7 +225,7 @@ const WindowTool: React.FC = () => { rotation: [0, itemRotation, 0], side, }) - markWallDirty(event.node.id) + markHostDirty(event.node.id) } else { useScene.getState().updateNode(draftRef.current.id, { position: [clampedX, clampedY, 0], @@ -362,65 +362,18 @@ const WindowTool: React.FC = () => { // so a window can sit anywhere inside the face profile — including // the gable pediment triangle. - const worldToBuildingLocal = (point: Vector3): [number, number, number] => { - // The tool's cursor group renders in the building's local frame — - // same conversion as the roof accessory tools (e.g. SkylightTool). - 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] - } + const resolveRoofTarget = (event: RoofEvent) => + resolveRoofWallOpeningTarget({ + event, + width: draftRef.current?.width ?? 1.5, + height: draftRef.current?.height ?? 1.5, + ignoreId: draftRef.current?.id, + vertical: { kind: 'free', snap: snapToHalf }, + }) - 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 ?? 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>, - 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 updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { + const pose = getRoofWallOpeningCursorPose(target, roof) + if (pose) updateCursor(pose.position, pose.rotationY, target.valid) } const onRoofHover = (event: RoofEvent) => { @@ -434,26 +387,26 @@ const WindowTool: React.FC = () => { } 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) { useScene.getState().updateNode(draftRef.current.id, { position, rotation: [0, 0, 0], - roofFace: hit.face.id, + roofFace: face.id, }) } else { const node = WindowNode.parse({ position, rotation: [0, 0, 0], side: 'front', - roofSegmentId: hit.segment.id, - roofFace: hit.face.id, - parentId: hit.segment.id, + roofSegmentId: segment.id, + roofFace: face.id, + parentId: segment.id, metadata: { isTransient: true }, }) - useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + useScene.getState().createNode(node, segment.id as AnyNodeId) draftRef.current = node } updateRoofCursor(target, event.node as RoofNode) @@ -464,7 +417,7 @@ const WindowTool: React.FC = () => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) if (!target?.valid) return - const { hit, position } = target + const { segment, face, position } = target const draft = draftRef.current draftRef.current = null @@ -482,9 +435,9 @@ const WindowTool: React.FC = () => { position, rotation: [0, 0, 0], side: 'front', - roofSegmentId: hit.segment.id, - roofFace: hit.face.id, - parentId: hit.segment.id, + roofSegmentId: segment.id, + roofFace: face.id, + parentId: segment.id, width: draft.width, height: draft.height, windowType: draft.windowType, @@ -503,10 +456,10 @@ const WindowTool: React.FC = () => { 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 // 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] }) useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 1a5d7991..323cca7a 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -110,12 +110,12 @@ export const RoofSystem = () => { // previous cut shape (stale CSG) once the user exits segment // edit mode. Registry-driven so the viewer stays kind-agnostic. 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 — // their dirty marks belong to that system, not to this loop. if ( def?.capabilities?.roofAccessory && - !def.capabilities.roofAccessory.cascadesViaHostSegment + !def.capabilities.roofAccessory.dirtyHandledByOwnSystem ) { const segId = (node as { roofSegmentId?: string }).roofSegmentId const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined