From 8ce26154d9b57466de254ff2a8c3f08d37ae6bb1 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 9 Jun 2026 13:17:04 -0400 Subject: [PATCH 01/15] fix: respect scene depth for site and ceiling handles --- .../ceiling-selection-affordance-system.tsx | 16 +++------ .../tools/shared/polygon-editor.tsx | 33 +++++++++++++------ .../tools/site/site-boundary-editor.tsx | 21 +++++++++--- 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index c61b8665..31134980 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -18,9 +18,9 @@ const BRACKET_THICKNESS = 0.04 const BRACKET_HEIGHT = 0.04 const BRACKET_Y_OFFSET = 0.035 const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28] -// Draw the corner handles after everything else and with depth testing -// off (see materials below) so they stay visible — and clickable — even -// when a wall, roof, or the ceiling itself would otherwise occlude them. +// Draw the corner handles after the ceiling surface so they read cleanly +// when unobstructed, while material depth testing still lets other scene +// geometry hide them. const CORNER_RENDER_ORDER = 1000 type CornerBracketData = { @@ -189,7 +189,7 @@ const CornerBracket = ({ - + ) } diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 2257bcf9..8f75d69f 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -6,6 +6,7 @@ import { BufferGeometry, Color, CylinderGeometry, + DoubleSide, ExtrudeGeometry, Float32BufferAttribute, type Line, @@ -20,7 +21,6 @@ import { ARROW_COLOR as EDGE_ARROW_COLOR, ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR, ARROW_SCALE as EDGE_ARROW_SCALE, - useArrowMaterial, useInvisibleHitAreaMaterial, } from '../../editor/node-arrow-handles' import { snapToHalf } from '../item/placement-math' @@ -181,7 +181,7 @@ function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMateri () => new MeshBasicNodeMaterial({ color: new Color('#ffffff'), - depthTest: false, + depthTest: true, depthWrite: true, opacity: 1, transparent: true, @@ -198,11 +198,24 @@ function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMateri return material } +function usePolygonArrowMaterial(): MeshBasicNodeMaterial { + return useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(EDGE_ARROW_COLOR), + depthTest: true, + depthWrite: true, + opacity: 1, + side: DoubleSide, + transparent: true, + }), + [], + ) +} + // One mesh per handle: lives on SCENE_LAYER with a node material so the -// post-processing ink-edge pass outlines it, and carries the pointer handlers -// directly so it stays grabbable — matching the registry arrow gizmos in -// node-arrow-handles.tsx. No paired hit mesh is needed; the R3F event -// raycaster picks SCENE_LAYER meshes too. +// post-processing ink-edge pass outlines it. The visual material still +// depth-tests, so walls/items in front can occlude it. function OutlinedCylinderHandle({ radius, height, @@ -290,7 +303,7 @@ function OutlinedEdgeArrowHandle({ rotationY: number scale: number } & PolygonHandleHandlers) { - const material = useArrowMaterial() + const material = usePolygonArrowMaterial() useEffect(() => { material.color.set(color) }, [color, material]) @@ -741,9 +754,9 @@ export const PolygonEditor: React.FC = ({ const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2 - // Interactive handles are single SCENE_LAYER node-material meshes (like the - // registry arrow gizmos) so the ink-edge pass outlines them while they stay - // grabbable. The edge BAR and border line stay on EDITOR_LAYER, visual-only + // Interactive handles are SCENE_LAYER node-material meshes so the ink-edge + // pass outlines them while normal scene depth can hide them. The edge BAR and + // border line stay on EDITOR_LAYER, visual-only // (raycast disabled) so they never steal clicks from the vertex/midpoint // handles overlapping them — edge dragging starts from the chevron arrow // outside the polygon edge. diff --git a/packages/editor/src/components/tools/site/site-boundary-editor.tsx b/packages/editor/src/components/tools/site/site-boundary-editor.tsx index 5a77f886..41da633e 100644 --- a/packages/editor/src/components/tools/site/site-boundary-editor.tsx +++ b/packages/editor/src/components/tools/site/site-boundary-editor.tsx @@ -38,6 +38,7 @@ const SITE_FLAG_HALO_COLOR = '#6366f1' type TintableMaterial = { color?: Color + depthTest: boolean depthWrite: boolean opacity: number needsUpdate: boolean @@ -69,10 +70,9 @@ function SiteFlagModel({ mesh.frustumCulled = false mesh.raycast = NO_RAYCAST mesh.receiveShadow = false - mesh.renderOrder = 1010 mesh.material = new MeshBasicNodeMaterial({ color: new Color(ARROW_COLOR), - depthTest: false, + depthTest: true, depthWrite: opacity >= 0.999, opacity, transparent: opacity < 0.999, @@ -93,6 +93,7 @@ function SiteFlagModel({ for (const material of materials as Array) { material.color?.copy(color) + material.depthTest = true material.opacity = opacity material.transparent = opacity < 0.999 material.depthWrite = opacity >= 0.999 @@ -217,11 +218,23 @@ function SiteFlagFallback({ - + = 0.999} + opacity={opacity} + transparent={opacity < 0.999} + /> - + = 0.999} + opacity={opacity} + transparent={opacity < 0.999} + /> ) From 265acdb2be5e7e922074991e055449e02f6b1ee8 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 9 Jun 2026 14:49:42 -0400 Subject: [PATCH 02/15] fix: improve ceiling handle feedback and WebGPU placeholders --- .../ceiling-selection-affordance-system.tsx | 367 ++++++++++++++++-- .../systems/ceiling/ceiling-system.tsx | 82 +++- .../systems/roof/roof-edit-system.tsx | 2 + .../tools/select/box-select-state.ts | 25 +- .../tools/shared/polygon-editor.tsx | 147 ++++++- .../nodes/src/ceiling/boundary-editor.tsx | 46 ++- .../nodes/src/shared/placeholder-geometry.ts | 10 +- packages/nodes/src/site/renderer.tsx | 25 +- .../src/systems/ceiling/ceiling-system.tsx | 2 + .../viewer/src/systems/roof/roof-system.tsx | 8 + .../viewer/src/systems/stair/stair-system.tsx | 2 + 11 files changed, 661 insertions(+), 55 deletions(-) diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 31134980..c5435948 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -5,19 +5,29 @@ import { emitter, resolveLevelId, sceneRegistry, + useLiveNodeOverrides, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { createPortal, type ThreeEvent } from '@react-three/fiber' -import { useEffect, useMemo, useState } from 'react' -import type { Object3D } from 'three' +import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' +import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' +import { snapToHalf } from '../../tools/item/placement-math' +import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' const BRACKET_THICKNESS = 0.04 const BRACKET_HEIGHT = 0.04 const BRACKET_Y_OFFSET = 0.035 const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28] +const HANDLE_COLOR = '#d4d4d4' +const HANDLE_HOVER_COLOR = '#818cf8' +const HANDLE_OPACITY = 0.72 +const HANDLE_HOVER_OPACITY = 0.92 +const HANDLE_DRAG_THRESHOLD_PX = 4 +const SHARED_HANDLE_BOX_GEOMETRY = new BoxGeometry(1, 1, 1) // Draw the corner handles after the ceiling surface so they read cleanly // when unobstructed, while material depth testing still lets other scene // geometry hide them. @@ -25,13 +35,58 @@ const CORNER_RENDER_ORDER = 1000 type CornerBracketData = { corner: [number, number] + index: number + incomingEdgeIndex: number incomingDirection: [number, number] + outgoingEdgeIndex: number outgoingDirection: [number, number] incomingLength: number outgoingLength: number cornerStrength: number } +type CornerDragState = { + ceilingId: CeilingNode['id'] + cornerIndex: number + didDrag: boolean + initialPolygon: Array<[number, number]> + inputDraggingSet: boolean + pointerId: number + previewPolygon: Array<[number, number]> | null + previousSnappedPosition: [number, number] | null + previousInputDragging: boolean + startClientX: number + startClientY: number + startPlanePosition: [number, number] +} + +function stopHandlePointerDown(event: ThreeEvent) { + event.stopPropagation() + suppressBoxSelectForPointer(event, { markHandled: false }) +} + +function suppressNextClick() { + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) +} + +function clearCornerDragPreview(drag: CornerDragState) { + if (drag.didDrag) { + useLiveNodeOverrides.getState().clear(drag.ceilingId) + useScene.getState().markDirty(drag.ceilingId) + } + if (drag.inputDraggingSet) { + useViewer.getState().setInputDragging(drag.previousInputDragging) + } +} + export const CeilingSelectionAffordanceSystem = () => { const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) @@ -79,11 +134,221 @@ const CeilingSelectionAffordance = ({ ceiling: CeilingNode levelId: string }) => { + const { camera, gl } = useThree() const [levelObject, setLevelObject] = useState( () => sceneRegistry.nodes.get(levelId) ?? null, ) + const [hoveredCornerIndex, setHoveredCornerIndex] = useState(null) + const [draggedCornerIndex, setDraggedCornerIndex] = useState(null) + const [previewPolygon, setPreviewPolygon] = useState | null>(null) + const dragRef = useRef(null) + const raycasterRef = useRef(new Raycaster()) + const ndcRef = useRef(new Vector2()) + const planeRef = useRef(new Plane()) + const planePointRef = useRef(new Vector3()) + const planeNormalRef = useRef(new Vector3()) + const planeOriginRef = useRef(new Vector3()) + const intersectionRef = useRef(new Vector3()) + const localIntersectionRef = useRef(new Vector3()) - const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon]) + const displayPolygon = previewPolygon ?? ceiling.polygon + const activeCornerIndex = draggedCornerIndex ?? hoveredCornerIndex + const corners = useMemo(() => buildCornerBrackets(displayPolygon), [displayPolygon]) + const highlightedEdgeIndices = useMemo(() => { + const next = new Set() + if (activeCornerIndex === null || displayPolygon.length < 2) return next + next.add(activeCornerIndex) + next.add((activeCornerIndex - 1 + displayPolygon.length) % displayPolygon.length) + return next + }, [activeCornerIndex, displayPolygon.length]) + const highlightedCornerIndices = useMemo(() => { + const next = new Set() + if (activeCornerIndex === null || displayPolygon.length < 2) return next + next.add(activeCornerIndex) + next.add((activeCornerIndex - 1 + displayPolygon.length) % displayPolygon.length) + next.add((activeCornerIndex + 1) % displayPolygon.length) + return next + }, [activeCornerIndex, displayPolygon.length]) + + useEffect(() => { + if (activeCornerIndex === null) return + + useViewer.getState().setHoveredId(ceiling.id) + return () => { + if (useViewer.getState().hoveredId === ceiling.id) { + useViewer.getState().setHoveredId(null) + } + } + }, [activeCornerIndex, ceiling.id]) + + const selectCeilingForEdit = useCallback(() => { + const editor = useEditor.getState() + editor.setMovingNode(null) + editor.setMovingWallEndpoint(null) + editor.setCurvingWall(null) + editor.setEditingHole(null) + editor.setMode('select') + useViewer.getState().setSelection({ selectedIds: [ceiling.id] }) + }, [ceiling.id]) + + const getHandlePlanePoint = useCallback( + (event: MouseEvent | PointerEvent): [number, number] | null => { + if (!levelObject) return null + + const rect = gl.domElement.getBoundingClientRect() + ndcRef.current.set( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ) + raycasterRef.current.setFromCamera(ndcRef.current, camera) + + planePointRef.current.set(0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0) + levelObject.localToWorld(planePointRef.current) + + planeOriginRef.current.set(0, 0, 0) + levelObject.localToWorld(planeOriginRef.current) + planeNormalRef.current.set(0, 1, 0) + levelObject.localToWorld(planeNormalRef.current) + planeNormalRef.current.sub(planeOriginRef.current).normalize() + planeRef.current.setFromNormalAndCoplanarPoint(planeNormalRef.current, planePointRef.current) + + const hit = raycasterRef.current.ray.intersectPlane(planeRef.current, intersectionRef.current) + if (!hit) return null + + localIntersectionRef.current.copy(intersectionRef.current) + levelObject.worldToLocal(localIntersectionRef.current) + return [localIntersectionRef.current.x, localIntersectionRef.current.z] + }, + [camera, ceiling.height, gl.domElement, levelObject], + ) + + const handleCornerPointerDown = useCallback( + (corner: CornerBracketData, event: ThreeEvent) => { + if (event.button !== 0) return + stopHandlePointerDown(event) + + const startPlanePosition = getHandlePlanePoint(event.nativeEvent) + if (!startPlanePosition) return + const initialCorner = ceiling.polygon[corner.index] + if (!initialCorner) return + + dragRef.current = { + ceilingId: ceiling.id, + cornerIndex: corner.index, + didDrag: false, + initialPolygon: ceiling.polygon.map(([x, z]) => [x, z] as [number, number]), + inputDraggingSet: false, + pointerId: event.pointerId, + previewPolygon: null, + previousSnappedPosition: [initialCorner[0], initialCorner[1]], + previousInputDragging: useViewer.getState().inputDragging, + startClientX: event.nativeEvent.clientX, + startClientY: event.nativeEvent.clientY, + startPlanePosition, + } + }, + [ceiling.id, ceiling.polygon, getHandlePlanePoint], + ) + + useEffect(() => { + const handlePointerMove = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || drag.ceilingId !== ceiling.id) return + if (event.pointerId !== drag.pointerId) return + + const dragDistance = Math.hypot( + event.clientX - drag.startClientX, + event.clientY - drag.startClientY, + ) + + const planePosition = getHandlePlanePoint(event) + if (!planePosition) return + + if (!drag.didDrag) { + if (dragDistance < HANDLE_DRAG_THRESHOLD_PX) return + + drag.didDrag = true + drag.inputDraggingSet = true + useViewer.getState().setInputDragging(true) + setDraggedCornerIndex(drag.cornerIndex) + selectCeilingForEdit() + sfxEmitter.emit('sfx:item-pick') + } + + const initialCorner = drag.initialPolygon[drag.cornerIndex] + if (!initialCorner) return + + const nextPosition: [number, number] = [ + initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]), + initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]), + ] + + if ( + drag.previousSnappedPosition && + (nextPosition[0] !== drag.previousSnappedPosition[0] || + nextPosition[1] !== drag.previousSnappedPosition[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + drag.previousSnappedPosition = nextPosition + + const nextPolygon = drag.initialPolygon.map((polygonPoint, index) => + index === drag.cornerIndex ? nextPosition : polygonPoint, + ) + + drag.previewPolygon = nextPolygon + setPreviewPolygon(nextPolygon) + useLiveNodeOverrides.getState().set(drag.ceilingId, { polygon: nextPolygon }) + useScene.getState().markDirty(drag.ceilingId) + } + + const finishDrag = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + + dragRef.current = null + setDraggedCornerIndex(null) + setPreviewPolygon(null) + + if (drag.didDrag) { + event.preventDefault() + suppressNextClick() + + if (drag.previewPolygon) { + useScene.getState().updateNode(drag.ceilingId, { polygon: drag.previewPolygon }) + useViewer.getState().setSelection({ selectedIds: [drag.ceilingId] }) + } + + sfxEmitter.emit('sfx:item-place') + } + + clearCornerDragPreview(drag) + } + + const cancelDrag = (event: PointerEvent) => { + const drag = dragRef.current + if (!drag || event.pointerId !== drag.pointerId) return + + dragRef.current = null + setDraggedCornerIndex(null) + setPreviewPolygon(null) + clearCornerDragPreview(drag) + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', finishDrag, true) + window.addEventListener('pointercancel', cancelDrag, true) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', finishDrag, true) + window.removeEventListener('pointercancel', cancelDrag, true) + + const drag = dragRef.current + if (!drag || drag.ceilingId !== ceiling.id) return + dragRef.current = null + clearCornerDragPreview(drag) + } + }, [ceiling.id, getHandlePlanePoint, selectCeilingForEdit]) useEffect(() => { let frameId = 0 @@ -116,7 +381,26 @@ const CeilingSelectionAffordance = ({ return createPortal( {corners.map((corner, index) => ( - + { + setHoveredCornerIndex((current) => { + if (hovered) return corner.index + return current === corner.index ? null : current + }) + }} + onPointerDown={(event) => handleCornerPointerDown(corner, event)} + /> ))} , levelObject, @@ -126,21 +410,29 @@ const CeilingSelectionAffordance = ({ const CornerBracket = ({ ceiling, corner, + highlightIncoming, + highlightOutgoing, + isHovered, + isLinkedHovered, + onHoverChange, + onPointerDown, }: { ceiling: CeilingNode corner: CornerBracketData + highlightIncoming: boolean + highlightOutgoing: boolean + isHovered: boolean + isLinkedHovered: boolean + onHoverChange: (hovered: boolean) => void + onPointerDown: (event: ThreeEvent) => void }) => { - const [isHovered, setIsHovered] = useState(false) - const color = '#d4d4d4' - const opacity = 0.72 - const cubeColor = isHovered ? '#818cf8' : '#d4d4d4' - const cubeOpacity = isHovered ? 0.92 : 0.72 + const cubeHighlighted = isHovered || isLinkedHovered + const cubeColor = cubeHighlighted ? HANDLE_HOVER_COLOR : HANDLE_COLOR + const cubeOpacity = cubeHighlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY const handleClick = (e: ThreeEvent) => { e.stopPropagation() - const nodes = useScene.getState().nodes - useEditor.getState().setMovingNode(null) useEditor.getState().setMovingWallEndpoint(null) useEditor.getState().setCurvingWall(null) @@ -160,33 +452,39 @@ const CornerBracket = ({ return ( { e.stopPropagation() - setIsHovered(true) + onHoverChange(true) }} onPointerLeave={(e) => { e.stopPropagation() - setIsHovered(false) + onHoverChange(false) }} renderOrder={CORNER_RENDER_ORDER} + scale={HIT_BOX_SIZE} > - ) => void - opacity: number + onHoverChange: (hovered: boolean) => void + onPointerDown: (event: ThreeEvent) => void }) => { const angle = Math.atan2(direction[1], direction[0]) const position: [number, number, number] = [ @@ -221,13 +523,29 @@ const BracketLeg = ({ return ( { + e.stopPropagation() + onHoverChange(true) + }} + onPointerLeave={(e) => { + e.stopPropagation() + onHoverChange(false) + }} position={position} renderOrder={CORNER_RENDER_ORDER} rotation={[0, angle, 0]} + scale={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} > - - + ) } @@ -253,7 +571,10 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat return { corner, + index, + incomingEdgeIndex: (index - 1 + polygon.length) % polygon.length, incomingDirection, + outgoingEdgeIndex: index, outgoingDirection, incomingLength: getBracketLength(incomingLength), outgoingLength: getBracketLength(outgoingLength), diff --git a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx index f557b3fd..80d1403f 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx @@ -1,17 +1,89 @@ import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' +import { Color, type Material, type Mesh } from 'three' import useEditor from '../../../store/use-editor' +const CEILING_GRID_HIGHLIGHT_COLOR = '#ffffff' +const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial' +const CEILING_GRID_HIGHLIGHT_MATERIAL_KEY = '__pascalCeilingGridHighlightMaterial' + +type CeilingGridUserData = { + [CEILING_GRID_BASE_MATERIAL_KEY]?: Material | Material[] + [CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]?: Material | Material[] +} + +type HighlightableMaterial = Material & { + color?: Color + depthWrite?: boolean + needsUpdate?: boolean + opacity?: number + transparent?: boolean +} + +function cloneCeilingGridHighlightMaterial(material: Material | Material[]): Material | Material[] { + const cloneOne = (entry: Material): Material => { + const clone = entry.clone() as HighlightableMaterial + if (clone.color instanceof Color) { + clone.color.set(CEILING_GRID_HIGHLIGHT_COLOR) + } + clone.depthWrite = false + clone.opacity = 1 + clone.transparent = true + clone.needsUpdate = true + return clone + } + + return Array.isArray(material) ? material.map(cloneOne) : cloneOne(material) +} + +function disposeMaterial(material: Material | Material[] | undefined) { + if (!material) return + const materials = Array.isArray(material) ? material : [material] + for (const entry of materials) { + entry.dispose() + } +} + +function setCeilingGridHighlighted(ceilingGrid: Mesh, highlighted: boolean) { + const userData = ceilingGrid.userData as CeilingGridUserData + + if (highlighted) { + if (!userData[CEILING_GRID_BASE_MATERIAL_KEY]) { + userData[CEILING_GRID_BASE_MATERIAL_KEY] = ceilingGrid.material + userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY] = cloneCeilingGridHighlightMaterial( + ceilingGrid.material, + ) + } + + const highlightMaterial = userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY] + if (highlightMaterial) { + ceilingGrid.material = highlightMaterial + } + return + } + + const baseMaterial = userData[CEILING_GRID_BASE_MATERIAL_KEY] + if (baseMaterial) { + ceilingGrid.material = baseMaterial + } + disposeMaterial(userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]) + delete userData[CEILING_GRID_BASE_MATERIAL_KEY] + delete userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY] +} + export const CeilingSystem = () => { const tool = useEditor((state) => state.tool) const selectedItem = useEditor((state) => state.selectedItem) const movingNode = useEditor((state) => state.movingNode) const selectedIds = useViewer((state) => state.selection.selectedIds) const activeLevelId = useViewer((state) => state.selection.levelId) + const hoveredId = useViewer((state) => state.hoveredId) useEffect(() => { const nodes = useScene.getState().nodes + const hoveredNode = hoveredId ? nodes[hoveredId as AnyNodeId] : null + const hoveredCeilingId = hoveredNode?.type === 'ceiling' ? hoveredNode.id : null const levelsToShowCeilings = new Set() @@ -54,7 +126,7 @@ export const CeilingSystem = () => { ceilings.forEach((ceiling) => { const mesh = sceneRegistry.nodes.get(ceiling) if (mesh) { - const ceilingGrid = mesh.getObjectByName('ceiling-grid') + const ceilingGrid = mesh.getObjectByName('ceiling-grid') as Mesh | undefined if (ceilingGrid) { let belongsToVisibleLevel = false let currentId: string | null = ceiling @@ -68,14 +140,18 @@ export const CeilingSystem = () => { currentId = node?.parentId as string | null } + const shouldHighlightGrid = ceiling === hoveredCeilingId const shouldShowGrid = - belongsToVisibleLevel || (levelsToShowCeilings.size === 0 && isCeilingToolActive) + shouldHighlightGrid || + belongsToVisibleLevel || + (levelsToShowCeilings.size === 0 && isCeilingToolActive) + setCeilingGridHighlighted(ceilingGrid, shouldHighlightGrid) ceilingGrid.visible = shouldShowGrid ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden } } }) - }, [tool, selectedItem, movingNode, selectedIds, activeLevelId]) + }, [tool, selectedItem, movingNode, selectedIds, activeLevelId, hoveredId]) return null } diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.tsx index 094ca94b..3b186742 100644 --- a/packages/editor/src/components/systems/roof/roof-edit-system.tsx +++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx @@ -18,6 +18,8 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry { // meshes are drawn. An empty position (count 0) leaves WebGPU vertex buffer // slot 0 unbound and the draw is rejected, poisoning the command encoder. g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + g.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + g.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) // Match the four material slots the roof-segment renderer's material // array expects (0=top, 1=side, 2=interior, 3=shingle). Without these // groups, mesh.material is a single-material lookup that mismatches diff --git a/packages/editor/src/components/tools/select/box-select-state.ts b/packages/editor/src/components/tools/select/box-select-state.ts index a3c2ca4d..941a962b 100644 --- a/packages/editor/src/components/tools/select/box-select-state.ts +++ b/packages/editor/src/components/tools/select/box-select-state.ts @@ -9,6 +9,10 @@ type PointerEventLike = { nativeEvent?: PointerEvent | PointerEventLike } +type SuppressBoxSelectOptions = { + markHandled?: boolean +} + function pointerIdFor(event: PointerEvent | PointerEventLike): number | null { if ('pointerId' in event && typeof event.pointerId === 'number') { return event.pointerId @@ -28,8 +32,12 @@ export function markBoxSelectHandled() { }, 50) } -export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLike) { - markBoxSelectHandled() +export function suppressBoxSelectForPointer( + event: PointerEvent | PointerEventLike, + options: SuppressBoxSelectOptions = {}, +) { + const markHandled = options.markHandled ?? true + if (markHandled) markBoxSelectHandled() const pointerId = pointerIdFor(event) if (pointerId === null || suppressedPointerIds.has(pointerId)) return @@ -38,7 +46,7 @@ export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLi const clear = (releaseEvent?: PointerEvent) => { if (releaseEvent && releaseEvent.pointerId !== pointerId) return - markBoxSelectHandled() + if (markHandled) markBoxSelectHandled() suppressedPointerIds.delete(pointerId) const cleanup = suppressionCleanups.get(pointerId) suppressionCleanups.delete(pointerId) @@ -48,15 +56,18 @@ export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLi const onPointerUp = (releaseEvent: PointerEvent) => clear(releaseEvent) const onPointerCancel = (releaseEvent: PointerEvent) => clear(releaseEvent) const onBlur = () => clear() + // Click-preserving handle interactions need suppression cleared before + // canvas-level pointerup handlers decide whether to block the follow-up click. + const releaseListenerOptions = markHandled ? undefined : { capture: true } const cleanup = () => { - window.removeEventListener('pointerup', onPointerUp) - window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('pointerup', onPointerUp, releaseListenerOptions) + window.removeEventListener('pointercancel', onPointerCancel, releaseListenerOptions) window.removeEventListener('blur', onBlur) } suppressionCleanups.set(pointerId, cleanup) - window.addEventListener('pointerup', onPointerUp) - window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('pointerup', onPointerUp, releaseListenerOptions) + window.addEventListener('pointercancel', onPointerCancel, releaseListenerOptions) window.addEventListener('blur', onBlur) } diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 8f75d69f..2d651f39 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -3,6 +3,7 @@ import { SCENE_LAYER, useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent } from '@react-three/fiber' import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { + BoxGeometry, BufferGeometry, Color, CylinderGeometry, @@ -24,6 +25,7 @@ import { useInvisibleHitAreaMaterial, } from '../../editor/node-arrow-handles' import { snapToHalf } from '../item/placement-math' +import { suppressBoxSelectForPointer } from '../select/box-select-state' const Y_OFFSET = 0.02 // Per-side resize arrows: indigo chevrons that match the registry arrow @@ -104,6 +106,8 @@ export interface PolygonEditorProps { onVertexHoverChange?: (vertexIndex: number | null) => void /** Called when a midpoint add-vertex handle enters or leaves hover. */ onMidpointHoverChange?: (edgeIndex: number | null) => void + /** Called when an edge move handle enters or leaves hover. */ + onEdgeHoverChange?: (edgeIndex: number | null) => void /** Called when any polygon drag starts or ends. */ onDragStateChange?: (isDragging: boolean) => void /** Called once when a polygon drag starts. */ @@ -114,6 +118,8 @@ export interface PolygonEditorProps { showBorderLine?: boolean /** Whether midpoint handles can add new vertices. */ showMidpointHandles?: boolean + /** Whether hovering a handle should also tint its connected edges and endpoint handles. */ + highlightConnectedHandles?: boolean /** Optional vertex handle renderer for host-specific affordances. */ renderVertexHandle?: PolygonVertexHandleRenderer /** Optional midpoint handle renderer for host-specific add-vertex affordances. */ @@ -127,6 +133,7 @@ export interface PolygonEditorProps { const MIN_HANDLE_HEIGHT = 0.15 const EDGE_HANDLE_HEIGHT = 0.06 const EDGE_HANDLE_THICKNESS = 0.12 +const EDGE_HANDLE_GEOMETRY = new BoxGeometry(1, 1, 1) function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null { const dx = end[0] - start[0] @@ -137,6 +144,11 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number, return [-dz / length, dx / length] } +function stopHandlePointerDown(event: ThreeEvent) { + event.stopPropagation() + suppressBoxSelectForPointer(event, { markHandled: false }) +} + type HandleClickHandler = (event: ThreeEvent) => void type HandlePointerHandler = (event: ThreeEvent) => void @@ -324,6 +336,47 @@ function OutlinedEdgeArrowHandle({ ) } +function HighlightedEdgeSegment({ + end, + start, + y, +}: { + end: [number, number] + start: [number, number] + y: number +}) { + const geometry = useMemo(() => { + const nextGeometry = new BufferGeometry() + nextGeometry.setAttribute( + 'position', + new Float32BufferAttribute([start[0], y, start[1], end[0], y, end[1]], 3), + ) + return nextGeometry + }, [end, start, y]) + + useEffect(() => () => geometry.dispose(), [geometry]) + + return ( + element conflicts with SVG type + frustumCulled={false} + geometry={geometry} + layers={EDITOR_LAYER} + raycast={NO_RAYCAST} + renderOrder={12} + > + + + ) +} + export const PolygonEditor: React.FC = ({ polygon, color = '#3b82f6', @@ -337,11 +390,13 @@ export const PolygonEditor: React.FC = ({ onBeforeVertexDrag, onVertexHoverChange, onMidpointHoverChange, + onEdgeHoverChange, onDragStateChange, onDragStart, onDragCommit, showBorderLine = true, showMidpointHandles = true, + highlightConnectedHandles = false, renderMidpointHandle, renderVertexHandle, }) => { @@ -442,6 +497,12 @@ export const PolygonEditor: React.FC = ({ useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange]) + useEffect(() => { + onEdgeHoverChange?.(hoveredEdge) + }, [hoveredEdge, onEdgeHoverChange]) + + useEffect(() => () => onEdgeHoverChange?.(null), [onEdgeHoverChange]) + const lineRef = useRef(null!) const previousPositionRef = useRef<[number, number] | null>(null) @@ -569,6 +630,47 @@ export const PolygonEditor: React.FC = ({ }) }, [displayPolygon]) + const activeVertexIndex = dragState?.mode === 'vertex' ? dragState.vertexIndex : hoveredVertex + const activeEdgeIndex = dragState?.mode === 'edge' ? dragState.edgeIndex : hoveredEdge + + const highlightedEdgeIndices = useMemo(() => { + const next = new Set() + const edgeCount = displayPolygon.length + if (!highlightConnectedHandles || edgeCount < 2) return next + + if (activeVertexIndex !== null && activeVertexIndex !== undefined) { + next.add(activeVertexIndex) + next.add((activeVertexIndex - 1 + edgeCount) % edgeCount) + } + if (hoveredMidpoint !== null) { + next.add(hoveredMidpoint) + } + if (activeEdgeIndex !== null && activeEdgeIndex !== undefined) { + next.add(activeEdgeIndex) + } + + return next + }, [ + activeEdgeIndex, + activeVertexIndex, + displayPolygon.length, + highlightConnectedHandles, + hoveredMidpoint, + ]) + + const isVertexLinkedHighlighted = useCallback( + (index: number) => { + if (!highlightConnectedHandles || highlightedEdgeIndices.size === 0) return false + const edgeCount = displayPolygon.length + if (edgeCount < 2) return false + return ( + highlightedEdgeIndices.has(index) || + highlightedEdgeIndices.has((index - 1 + edgeCount) % edgeCount) + ) + }, + [displayPolygon.length, highlightConnectedHandles, highlightedEdgeIndices], + ) + const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), []) useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) @@ -784,10 +886,28 @@ export const PolygonEditor: React.FC = ({ )} + {highlightConnectedHandles && + highlightedEdgeIndices.size > 0 && + Array.from(highlightedEdgeIndices).map((edgeIndex) => { + const start = displayPolygon[edgeIndex] + const end = displayPolygon[(edgeIndex + 1) % displayPolygon.length] + if (!(start && end)) return null + return ( + + ) + })} + {/* Vertex handles - blue cylinders that match surface height */} {displayPolygon.map(([x, z], index) => { const isHovered = hoveredVertex === index const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index + const isLinkedHighlighted = isVertexLinkedHighlighted(index) + const isHighlighted = isDragging || isHovered || isLinkedHighlighted const radius = 0.1 const height = handleHeight const point: [number, number] = [x!, z!] @@ -806,7 +926,7 @@ export const PolygonEditor: React.FC = ({ }, onPointerDown: (e) => { if (e.button !== 0) return - e.stopPropagation() + stopHandlePointerDown(e) setHoveredEdge(null) onBeforeVertexDrag?.(index, point) startDrag({ @@ -848,7 +968,7 @@ export const PolygonEditor: React.FC = ({ return ( = ({ }} onPointerDown={(e) => { if (e.button !== 0) return - e.stopPropagation() + stopHandlePointerDown(e) setHoveredEdge(null) startDrag({ isDragging: true, @@ -886,6 +1006,8 @@ export const PolygonEditor: React.FC = ({ edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => { const isHovered = hoveredEdge === index const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index + const isLinkedHighlighted = highlightedEdgeIndices.has(index) + const isHighlighted = isDragging || isHovered || isLinkedHighlighted const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET const arrowZ = midpoint[1] + outwardNormal[1] * EDGE_ARROW_OFFSET @@ -919,15 +1041,16 @@ export const PolygonEditor: React.FC = ({ which sits outside the polygon and never overlaps a vertex/midpoint handle. */} - @@ -935,7 +1058,7 @@ export const PolygonEditor: React.FC = ({ Points outward from the edge; dragging it translates only this edge's two vertices along the outward normal. */} { if (e.button !== 0) return @@ -943,7 +1066,7 @@ export const PolygonEditor: React.FC = ({ }} onPointerDown={(e) => { if (e.button !== 0) return - e.stopPropagation() + stopHandlePointerDown(e) beginEdgeDrag(e) }} onPointerEnter={(e) => { @@ -967,6 +1090,8 @@ export const PolygonEditor: React.FC = ({ !dragState && midpoints.map(([x, z], index) => { const isHovered = hoveredMidpoint === index + const isLinkedHighlighted = highlightedEdgeIndices.has(index) + const isHighlighted = isHovered || isLinkedHighlighted const radius = 0.06 const height = handleHeight const point: [number, number] = [x!, z!] @@ -978,7 +1103,7 @@ export const PolygonEditor: React.FC = ({ }, onPointerDown: (e) => { if (e.button !== 0) return - e.stopPropagation() + stopHandlePointerDown(e) onBeforeVertexDrag?.(index + 1, point) const insertedVertex = handleAddVertex(index, point) if (insertedVertex.vertexIndex >= 0) { @@ -1021,11 +1146,11 @@ export const PolygonEditor: React.FC = ({ return ( diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index eb174e50..0b06f5cf 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -1,9 +1,9 @@ 'use client' import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' -import { PolygonEditor } from '@pascal-app/editor' +import { PolygonEditor, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' /** * Phase 5 Stage D — ceiling boundary editor (registry-driven). @@ -23,6 +23,8 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = const updateNode = useScene((s) => s.updateNode) const markDirty = useScene((s) => s.markDirty) const setSelection = useViewer((s) => s.setSelection) + const setHoveredId = useViewer((s) => s.setHoveredId) + const ownsCeilingHoverRef = useRef(false) const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null @@ -48,10 +50,43 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = [ceilingId, markDirty], ) + const setCeilingHandleHover = useCallback( + (active: boolean) => { + if (active) { + ownsCeilingHoverRef.current = true + setHoveredId(ceilingId) + return + } + if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) { + setHoveredId(null) + } + ownsCeilingHoverRef.current = false + }, + [ceilingId, setHoveredId], + ) + + const handleHandleHoverChange = useCallback( + (index: number | null) => { + setCeilingHandleHover(index !== null) + }, + [setCeilingHandleHover], + ) + + const handleDragStateChange = useCallback( + (isDragging: boolean) => { + setCeilingHandleHover(isDragging) + }, + [setCeilingHandleHover], + ) + useEffect(() => { return () => { useLiveNodeOverrides.getState().clear(ceilingId) useScene.getState().markDirty(ceilingId) + if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) { + useViewer.getState().setHoveredId(null) + } + ownsCeilingHoverRef.current = false } }, [ceilingId]) @@ -61,10 +96,17 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = triggerSFX('sfx:item-place')} + onDragStart={() => triggerSFX('sfx:item-pick')} + onEdgeHoverChange={handleHandleHoverChange} + onMidpointHoverChange={handleHandleHoverChange} onPolygonChange={handlePolygonChange} onPolygonPreview={handlePolygonPreview} + onVertexHoverChange={handleHandleHoverChange} polygon={ceiling.polygon} surfaceHeight={ceiling.height ?? 2.5} /> diff --git a/packages/nodes/src/shared/placeholder-geometry.ts b/packages/nodes/src/shared/placeholder-geometry.ts index 287a45af..52ce511c 100644 --- a/packages/nodes/src/shared/placeholder-geometry.ts +++ b/packages/nodes/src/shared/placeholder-geometry.ts @@ -13,13 +13,17 @@ import { BufferGeometry, Float32BufferAttribute } from 'three' * (count 0) makes three.js create no GPU buffer for it, so vertex buffer slot 0 * is never bound and WebGPU rejects the draw with "Vertex buffer slot 0 … was * not set", which poisons the whole command encoder (cascading into "Invalid - * CommandBuffer" on every queue submit). Three real vertices give it a bound - * buffer; the `groupCount` count-0 groups keep nothing drawn while matching the - * mesh's material-array length so raycasts / BVH never index past the materials. + * CommandBuffer" on every queue submit). The zero normals and UVs keep lit + * node-material pipelines from compiling additional required-but-unbound + * vertex buffers. Three real vertices give it bound buffers; the `groupCount` + * count-0 groups keep nothing drawn while matching the mesh's material-array + * length so raycasts / BVH never index past the materials. */ export function createPlaceholderGeometry(groupCount = 0): BufferGeometry { const geometry = new BufferGeometry() geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3)) + geometry.setAttribute('normal', new Float32BufferAttribute(new Float32Array(9), 3)) + geometry.setAttribute('uv', new Float32BufferAttribute(new Float32Array(6), 2)) for (let group = 0; group < groupCount; group++) { geometry.addGroup(0, 0, group) } diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 33e3ba34..87d66dc1 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -15,8 +15,15 @@ import { useNodeEvents, useViewer, } from '@pascal-app/viewer' -import { useMemo, useRef } from 'react' -import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three' +import { useEffect, useMemo, useRef } from 'react' +import { + BufferGeometry, + Float32BufferAttribute, + type Group, + Path, + Shape, + ShapeGeometry, +} from 'three' import { MeshLambertNodeMaterial } from 'three/webgpu' const Y_OFFSET = 0.01 @@ -134,6 +141,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { if (!polygonPoints || polygonPoints.length < 2) return null return createBoundaryLineGeometry(polygonPoints) }, [polygonPoints]) + useEffect(() => () => lineGeometry?.dispose(), [lineGeometry]) + + const groundGeometry = useMemo(() => { + if (!groundShape) return null + return new ShapeGeometry(groundShape) + }, [groundShape]) + useEffect(() => () => groundGeometry?.dispose(), [groundGeometry]) const handlers = useNodeEvents(node, 'site') @@ -149,15 +163,14 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { ))} {/* Ground fill: site polygon with slab holes, occludes below-grade geometry */} - {groundShape && ( + {groundGeometry && ( - - + /> )} {/* Simple boundary line */} diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 496ec333..f98da183 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -131,6 +131,8 @@ export function generateCeilingGeometry( // the whole command encoder. const degenerate = new THREE.BufferGeometry() degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + degenerate.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + degenerate.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) return degenerate } diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 704b27e8..79dca73e 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -152,6 +152,14 @@ export const RoofSystem = () => { 'position', new THREE.Float32BufferAttribute(new Float32Array(9), 3), ) + placeholder.setAttribute( + 'normal', + new THREE.Float32BufferAttribute(new Float32Array(9), 3), + ) + placeholder.setAttribute( + 'uv', + new THREE.Float32BufferAttribute(new Float32Array(6), 2), + ) computeGeometryBoundsTree(placeholder) mesh.geometry = placeholder } diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index d000bea2..f5d09f7c 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -531,6 +531,8 @@ function createEmptyGeometry(): THREE.BufferGeometry { // unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"), // poisoning the command encoder. The count-0 groups keep nothing drawn. geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX) geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX) return geometry From f3c1407cae30eec8ca0303a3a979128d77849481 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 9 Jun 2026 15:58:43 -0400 Subject: [PATCH 03/15] fix: sync ceiling handles during polygon edits --- .../ceiling-selection-affordance-system.tsx | 66 ++++++++----------- .../nodes/src/ceiling/boundary-editor.tsx | 37 +++++++++-- 2 files changed, 57 insertions(+), 46 deletions(-) diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index c5435948..218beccd 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -42,7 +42,6 @@ type CornerBracketData = { outgoingDirection: [number, number] incomingLength: number outgoingLength: number - cornerStrength: number } type CornerDragState = { @@ -135,6 +134,13 @@ const CeilingSelectionAffordance = ({ levelId: string }) => { const { camera, gl } = useThree() + const liveOverride = useLiveNodeOverrides( + (state) => state.overrides.get(ceiling.id) as Partial | undefined, + ) + const effectiveCeiling = useMemo( + () => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling), + [ceiling, liveOverride], + ) const [levelObject, setLevelObject] = useState( () => sceneRegistry.nodes.get(levelId) ?? null, ) @@ -151,7 +157,7 @@ const CeilingSelectionAffordance = ({ const intersectionRef = useRef(new Vector3()) const localIntersectionRef = useRef(new Vector3()) - const displayPolygon = previewPolygon ?? ceiling.polygon + const displayPolygon = previewPolygon ?? effectiveCeiling.polygon const activeCornerIndex = draggedCornerIndex ?? hoveredCornerIndex const corners = useMemo(() => buildCornerBrackets(displayPolygon), [displayPolygon]) const highlightedEdgeIndices = useMemo(() => { @@ -173,13 +179,13 @@ const CeilingSelectionAffordance = ({ useEffect(() => { if (activeCornerIndex === null) return - useViewer.getState().setHoveredId(ceiling.id) + useViewer.getState().setHoveredId(effectiveCeiling.id) return () => { - if (useViewer.getState().hoveredId === ceiling.id) { + if (useViewer.getState().hoveredId === effectiveCeiling.id) { useViewer.getState().setHoveredId(null) } } - }, [activeCornerIndex, ceiling.id]) + }, [activeCornerIndex, effectiveCeiling.id]) const selectCeilingForEdit = useCallback(() => { const editor = useEditor.getState() @@ -188,8 +194,8 @@ const CeilingSelectionAffordance = ({ editor.setCurvingWall(null) editor.setEditingHole(null) editor.setMode('select') - useViewer.getState().setSelection({ selectedIds: [ceiling.id] }) - }, [ceiling.id]) + useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] }) + }, [effectiveCeiling.id]) const getHandlePlanePoint = useCallback( (event: MouseEvent | PointerEvent): [number, number] | null => { @@ -202,7 +208,7 @@ const CeilingSelectionAffordance = ({ ) raycasterRef.current.setFromCamera(ndcRef.current, camera) - planePointRef.current.set(0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0) + planePointRef.current.set(0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0) levelObject.localToWorld(planePointRef.current) planeOriginRef.current.set(0, 0, 0) @@ -219,7 +225,7 @@ const CeilingSelectionAffordance = ({ levelObject.worldToLocal(localIntersectionRef.current) return [localIntersectionRef.current.x, localIntersectionRef.current.z] }, - [camera, ceiling.height, gl.domElement, levelObject], + [camera, effectiveCeiling.height, gl.domElement, levelObject], ) const handleCornerPointerDown = useCallback( @@ -229,14 +235,14 @@ const CeilingSelectionAffordance = ({ const startPlanePosition = getHandlePlanePoint(event.nativeEvent) if (!startPlanePosition) return - const initialCorner = ceiling.polygon[corner.index] + const initialCorner = effectiveCeiling.polygon[corner.index] if (!initialCorner) return dragRef.current = { - ceilingId: ceiling.id, + ceilingId: effectiveCeiling.id, cornerIndex: corner.index, didDrag: false, - initialPolygon: ceiling.polygon.map(([x, z]) => [x, z] as [number, number]), + initialPolygon: effectiveCeiling.polygon.map(([x, z]) => [x, z] as [number, number]), inputDraggingSet: false, pointerId: event.pointerId, previewPolygon: null, @@ -247,13 +253,13 @@ const CeilingSelectionAffordance = ({ startPlanePosition, } }, - [ceiling.id, ceiling.polygon, getHandlePlanePoint], + [effectiveCeiling.id, effectiveCeiling.polygon, getHandlePlanePoint], ) useEffect(() => { const handlePointerMove = (event: PointerEvent) => { const drag = dragRef.current - if (!drag || drag.ceilingId !== ceiling.id) return + if (!drag || drag.ceilingId !== effectiveCeiling.id) return if (event.pointerId !== drag.pointerId) return const dragDistance = Math.hypot( @@ -344,11 +350,11 @@ const CeilingSelectionAffordance = ({ window.removeEventListener('pointercancel', cancelDrag, true) const drag = dragRef.current - if (!drag || drag.ceilingId !== ceiling.id) return + if (!drag || drag.ceilingId !== effectiveCeiling.id) return dragRef.current = null clearCornerDragPreview(drag) } - }, [ceiling.id, getHandlePlanePoint, selectCeilingForEdit]) + }, [effectiveCeiling.id, getHandlePlanePoint, selectCeilingForEdit]) useEffect(() => { let frameId = 0 @@ -379,10 +385,10 @@ const CeilingSelectionAffordance = ({ if (!levelObject || corners.length === 0) return null return createPortal( - + {corners.map((corner, index) => ( void onPointerDown: (event: ThreeEvent) => void }) => { - const angle = Math.atan2(direction[1], direction[0]) + const angle = -Math.atan2(direction[1], direction[0]) const position: [number, number, number] = [ direction[0] * (length / 2), 0, @@ -553,7 +559,7 @@ const BracketLeg = ({ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] { if (polygon.length < 3) return [] - const allCorners = polygon.map((corner, index) => { + return polygon.map((corner, index) => { const previous = polygon[(index - 1 + polygon.length) % polygon.length]! const next = polygon[(index + 1) % polygon.length]! const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number] @@ -563,11 +569,6 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat const incomingLength = Math.hypot(incomingVector[0], incomingVector[1]) const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1]) - const cornerStrength = - 1 - - Math.abs( - incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1], - ) return { corner, @@ -578,23 +579,8 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat outgoingDirection, incomingLength: getBracketLength(incomingLength), outgoingLength: getBracketLength(outgoingLength), - cornerStrength, } }) - - if (allCorners.length <= 4) { - return allCorners - } - - const selectedIndices = new Set( - allCorners - .map((corner, index) => ({ index, strength: corner.cornerStrength })) - .sort((a, b) => b.strength - a.strength) - .slice(0, 4) - .map(({ index }) => index), - ) - - return allCorners.filter((_, index) => selectedIndices.has(index)) } function normalize2D(vector: [number, number]): [number, number] { diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index 0b06f5cf..0ae77a9d 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -3,7 +3,7 @@ import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { PolygonEditor, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' /** * Phase 5 Stage D — ceiling boundary editor (registry-driven). @@ -25,8 +25,17 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = const setSelection = useViewer((s) => s.setSelection) const setHoveredId = useViewer((s) => s.setHoveredId) const ownsCeilingHoverRef = useRef(false) + const ownsPolygonPreviewRef = useRef(false) + const liveOverride = useLiveNodeOverrides((state) => { + if (ownsPolygonPreviewRef.current) return null + return state.overrides.get(ceilingId) as Partial | undefined + }) const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null + const effectiveCeiling = useMemo( + () => (ceiling && liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling), + [ceiling, liveOverride], + ) const handlePolygonChange = useCallback( (newPolygon: Array<[number, number]>) => { @@ -39,11 +48,13 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = const handlePolygonPreview = useCallback( (preview: ReadonlyArray | null) => { if (preview) { + ownsPolygonPreviewRef.current = true useLiveNodeOverrides.getState().set(ceilingId, { polygon: preview.map(([x, z]) => [x, z] as [number, number]), }) } else { useLiveNodeOverrides.getState().clear(ceilingId) + ownsPolygonPreviewRef.current = false } markDirty(ceilingId) }, @@ -74,15 +85,28 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = const handleDragStateChange = useCallback( (isDragging: boolean) => { + if (!isDragging) { + ownsPolygonPreviewRef.current = false + } setCeilingHandleHover(isDragging) }, [setCeilingHandleHover], ) + const handlePolygonEditorDragStart = useCallback(() => { + ownsPolygonPreviewRef.current = true + triggerSFX('sfx:item-pick') + }, []) + + const handlePolygonEditorBeforeVertexDrag = useCallback(() => { + ownsPolygonPreviewRef.current = true + }, []) + useEffect(() => { return () => { useLiveNodeOverrides.getState().clear(ceilingId) useScene.getState().markDirty(ceilingId) + ownsPolygonPreviewRef.current = false if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) { useViewer.getState().setHoveredId(null) } @@ -90,25 +114,26 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = } }, [ceilingId]) - if (!ceiling?.polygon || ceiling.polygon.length < 3) return null + if (!effectiveCeiling?.polygon || effectiveCeiling.polygon.length < 3) return null return ( triggerSFX('sfx:item-place')} - onDragStart={() => triggerSFX('sfx:item-pick')} + onDragStart={handlePolygonEditorDragStart} onEdgeHoverChange={handleHandleHoverChange} onMidpointHoverChange={handleHandleHoverChange} onPolygonChange={handlePolygonChange} onPolygonPreview={handlePolygonPreview} onVertexHoverChange={handleHandleHoverChange} - polygon={ceiling.polygon} - surfaceHeight={ceiling.height ?? 2.5} + polygon={effectiveCeiling.polygon} + surfaceHeight={effectiveCeiling.height ?? 2.5} /> ) } From 92078741cdec3d236daf81fe2db7e40a771b3b4b Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 9 Jun 2026 17:50:01 -0400 Subject: [PATCH 04/15] feat: add ceiling wall snap feedback --- .../src/components/editor/floorplan-panel.tsx | 39 ++- .../use-floorplan-background-placement.ts | 21 +- .../editor/wall-snap-beacon-layer.tsx | 149 ++++++++++- .../ceiling-selection-affordance-system.tsx | 20 +- .../tools/shared/polygon-editor.tsx | 34 ++- .../components/tools/wall/wall-drafting.ts | 12 +- .../tools/wall/wall-snap-geometry.test.ts | 13 + .../tools/wall/wall-snap-geometry.ts | 18 +- packages/editor/src/index.tsx | 9 + packages/editor/src/lib/ceiling-plan-snap.ts | 232 ++++++++++++++++++ .../src/store/use-wall-snap-indicator.ts | 2 + .../nodes/src/ceiling/boundary-editor.tsx | 38 ++- packages/nodes/src/ceiling/tool.tsx | 70 +----- 13 files changed, 563 insertions(+), 94 deletions(-) create mode 100644 packages/editor/src/lib/ceiling-plan-snap.ts diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 959df800..8304e0d9 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -64,6 +64,7 @@ import { import { createPortal } from 'react-dom' import { Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' +import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, buildFloorplanItemEntry, @@ -8425,17 +8426,22 @@ export function FloorplanPanel() { if (isCeilingBuildActive) { // Polygon vertex: grid (snapToHalf) + optional 45° angle snap from - // the previous vertex. Alignment runs only when angle snap is OFF - // (first vertex, or Shift held) — when the angle is being locked, - // pulling the vertex sideways would break it. + // the previous vertex. Wall magnetic snap may still win, while + // generic alignment runs only when angle snap is OFF (first vertex, + // or Shift held) so it does not pull a locked angle sideways. const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed - let snappedPoint = snapPolygonDraftPoint({ + const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: ceilingDraftPoints[ceilingDraftPoints.length - 1], angleSnap, }) - if (angleSnap) useAlignmentGuides.getState().clear() - else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) + const snappedPoint = resolveCeilingPlanPointSnap({ + rawPoint: planPoint, + fallbackPoint, + levelId, + altKey: event.altKey, + align: !angleSnap, + }).point emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => @@ -8676,6 +8682,7 @@ export function FloorplanPanel() { isPolygonBuildActive, isRoofBuildActive, isWallBuildActive, + levelId, publishFloorplanNavigationPose, smoothFloorplanNavigationView, referenceScaleDraft, @@ -8934,6 +8941,7 @@ export function FloorplanPanel() { isRoofBuildActive, isWallBuildActive, isZoneBuildActive, + levelId, roofDraftStart, setCursorPoint, setFenceDraftEnd, @@ -9110,25 +9118,33 @@ export function FloorplanPanel() { return } - const snappedPoint = snapPolygonDraftPoint({ + const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed + const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], - angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, + angleSnap, }) if (isCeilingBuildActive) { - emitFloorplanGridEvent('double-click', planPoint, event) + const snappedPoint = resolveCeilingPlanPointSnap({ + rawPoint: planPoint, + fallbackPoint, + levelId, + altKey: event.altKey, + align: !angleSnap, + }).point + emitFloorplanGridEvent('double-click', snappedPoint, event) handleCeilingPlacementConfirm(snappedPoint) return } if (isZoneBuildActive) { - handleZonePlacementConfirm(snappedPoint) + handleZonePlacementConfirm(fallbackPoint) } else { // Slab is registry-driven: forward the double-click so the 3D tool // commits the node (zone has no registry tool, so it commits locally). emitFloorplanGridEvent('double-click', planPoint, event) - handleSlabPlacementConfirm(snappedPoint) + handleSlabPlacementConfirm(fallbackPoint) } }, [ @@ -9142,6 +9158,7 @@ export function FloorplanPanel() { isPolygonDraftBuildActive, isRoofBuildActive, isZoneBuildActive, + levelId, shiftPressed, ], ) diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 31c013b6..6dd16c43 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -2,6 +2,7 @@ import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core' import { type MouseEvent as ReactMouseEvent, useCallback } from 'react' +import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { @@ -51,6 +52,7 @@ type UseFloorplanBackgroundPlacementArgs = { isRoofBuildActive: boolean isWallBuildActive: boolean isZoneBuildActive: boolean + levelId: string | null roofDraftStart: WallPlanPoint | null setCursorPoint: React.Dispatch> setFenceDraftEnd: React.Dispatch> @@ -107,6 +109,7 @@ export function useFloorplanBackgroundPlacement({ isRoofBuildActive, isWallBuildActive, isZoneBuildActive, + levelId, roofDraftStart, setCursorPoint, setFenceDraftEnd, @@ -149,17 +152,22 @@ export function useFloorplanBackgroundPlacement({ if (isCeilingBuildActive) { // Align the committed vertex the same way the move-preview did, so - // the placed point matches what the user saw. Skip when angle snap - // owns the vertex (matches the move branch). + // the placed point matches what the user saw. Wall magnetic snap may + // still win; generic alignment is skipped when angle snap owns the + // vertex (matches the move branch). const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed - let snappedPoint = snapPolygonDraftPoint({ + const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: ceilingDraftPoints[ceilingDraftPoints.length - 1], angleSnap, }) - if (!angleSnap) { - snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) - } + const snappedPoint = resolveCeilingPlanPointSnap({ + rawPoint: planPoint, + fallbackPoint, + levelId, + altKey: event.altKey, + align: !angleSnap, + }).point emitFloorplanGridEvent('click', snappedPoint, event) handleCeilingPlacementPoint(snappedPoint) @@ -322,6 +330,7 @@ export function useFloorplanBackgroundPlacement({ isRoofBuildActive, isWallBuildActive, isZoneBuildActive, + levelId, roofDraftStart, setCursorPoint, setFenceDraftEnd, diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index 278cab1d..9d3c2ed0 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -1,10 +1,23 @@ 'use client' -import { sceneRegistry } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + DEFAULT_WALL_HEIGHT, + getWallCurveFrameAt, + getWallCurveLength, + getWallThickness, + isCurvedWall, + resolveLevelId, + sceneRegistry, + spatialGridManager, + useScene, + type WallNode, +} from '@pascal-app/core' import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' -import { memo, useRef } from 'react' +import { memo, useMemo, useRef } from 'react' import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' @@ -34,6 +47,14 @@ const BEACON_HEIGHT = 2.5 // world-meter height of the pillar const BEACON_RADIUS = 0.018 // world-meter radius of the pillar const MARKER = 0.13 // world-meter base size of the floor glyph const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid +const WALL_TOP_HIGHLIGHT_LIFT = 0.035 +const WALL_TOP_HIGHLIGHT_HEIGHT = 0.018 +const WALL_TOP_HIGHLIGHT_OVERHANG = 0.14 +const WALL_TOP_GLOW_HEIGHT = 0.026 +const WALL_TOP_GLOW_OVERHANG = 0.36 +const WALL_TOP_END_OVERHANG = 0.08 +const CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH = 0.45 +const NO_RAYCAST = () => null // Shared resources — one material + unit geometries, so snap churn during a // drag doesn't rebuild GPU buffers (mirrors the alignment guide layer). @@ -45,17 +66,41 @@ const beaconMaterial = new MeshBasicNodeMaterial({ transparent: true, opacity: 0.9, }) +const wallTopHighlightMaterial = new MeshBasicNodeMaterial({ + color: BEACON_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.88, +}) +const wallTopHighlightGlowMaterial = new MeshBasicNodeMaterial({ + color: BEACON_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.26, +}) const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8) // Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as // a flat plate; circles/triangles lie flat via an X rotation at the mesh. const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1) +const WALL_TOP_HIGHLIGHT_GEOMETRY = new BoxGeometry(1, 1, 1) const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3) const CIRCLE_GEOMETRY = new CircleGeometry(1, 28) export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() { const point = useWallSnapIndicator((s) => s.point) const levelId = useViewer((s) => s.selection.levelId) + const nodes = useScene((s) => s.nodes) const groupRef = useRef(null) + const highlightedWalls = useMemo(() => { + if (!point?.wallIds?.length) return [] + return point.wallIds + .map((wallId) => nodes[wallId as AnyNodeId]) + .filter((node): node is WallNode => node?.type === 'wall' && node.visible !== false) + }, [nodes, point?.wallIds]) // Track the active level's building-local Y each frame so the beacon stands // on the floor being edited, not the building base — same source the @@ -70,6 +115,9 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() { if (!point) return null return ( + {highlightedWalls.map((wall) => ( + + ))} >) { + const levelId = resolveLevelId(wall, nodes as Record) + const slabElevation = spatialGridManager.getSlabElevationForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + ) + const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT +} + +function buildHighlightSegment(start: [number, number], end: [number, number]) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + if (length < 1e-6) return null + + return { + angle: -Math.atan2(dz, dx), + center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number], + length, + } +} + +function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] { + if (!isCurvedWall(wall)) { + const segment = buildHighlightSegment(wall.start, wall.end) + return segment ? [segment] : [] + } + + const sampleCount = Math.max( + 8, + Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH), + ) + const segments: WallTopHighlightSegment[] = [] + let previous = getWallCurveFrameAt(wall, 0).point + for (let index = 1; index <= sampleCount; index += 1) { + const current = getWallCurveFrameAt(wall, index / sampleCount).point + const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y]) + if (segment) segments.push(segment) + previous = current + } + return segments +} + +function WallTopHighlight({ + nodes, + wall, +}: { + nodes: Readonly> + wall: WallNode +}) { + const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall]) + const y = getWallTopY(wall, nodes) + const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24) + const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42) + + return ( + <> + {segments.map((segment, index) => ( + + + + + ))} + + ) +} + /** Floor glyph whose shape encodes which kind of geometry the point snapped to. */ function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) { const y = FLOOR_LIFT diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 218beccd..c45fb78d 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -13,6 +13,10 @@ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' +import { + clearCeilingSnapFeedback, + resolveCeilingPlanPointSnap, +} from '../../../lib/ceiling-plan-snap' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { snapToHalf } from '../../tools/item/placement-math' @@ -84,6 +88,7 @@ function clearCornerDragPreview(drag: CornerDragState) { if (drag.inputDraggingSet) { useViewer.getState().setInputDragging(drag.previousInputDragging) } + clearCeilingSnapFeedback() } export const CeilingSelectionAffordanceSystem = () => { @@ -284,10 +289,21 @@ const CeilingSelectionAffordance = ({ const initialCorner = drag.initialPolygon[drag.cornerIndex] if (!initialCorner) return - const nextPosition: [number, number] = [ + const rawNextPosition: [number, number] = [ + initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]), + initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]), + ] + const gridNextPosition: [number, number] = [ initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]), initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]), ] + const nextPosition = resolveCeilingPlanPointSnap({ + rawPoint: rawNextPosition, + fallbackPoint: gridNextPosition, + levelId, + excludeId: drag.ceilingId, + altKey: event.altKey, + }).point if ( drag.previousSnappedPosition && @@ -354,7 +370,7 @@ const CeilingSelectionAffordance = ({ dragRef.current = null clearCornerDragPreview(drag) } - }, [effectiveCeiling.id, getHandlePlanePoint, selectCeilingForEdit]) + }, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit]) useEffect(() => { let frameId = 0 diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 2d651f39..5d9472ec 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -79,6 +79,17 @@ type DragState = { pointerId: number } +export type PolygonEditorPlanPointSnapContext = { + rawPoint: [number, number] + gridPoint: [number, number] + mode: DragState['mode'] + vertexIndex: number | null + edgeIndex?: number + initialPosition: [number, number] + initialPolygon: Array<[number, number]> + nativeEvent?: GridEvent['nativeEvent'] +} + export interface PolygonEditorProps { polygon: Array<[number, number]> color?: string @@ -120,6 +131,8 @@ export interface PolygonEditorProps { showMidpointHandles?: boolean /** Whether hovering a handle should also tint its connected edges and endpoint handles. */ highlightConnectedHandles?: boolean + /** Optional host-owned point snapper. Defaults to the existing half-grid snap. */ + resolvePlanPoint?: (context: PolygonEditorPlanPointSnapContext) => [number, number] /** Optional vertex handle renderer for host-specific affordances. */ renderVertexHandle?: PolygonVertexHandleRenderer /** Optional midpoint handle renderer for host-specific add-vertex affordances. */ @@ -397,6 +410,7 @@ export const PolygonEditor: React.FC = ({ showBorderLine = true, showMidpointHandles = true, highlightConnectedHandles = false, + resolvePlanPoint, renderMidpointHandle, renderVertexHandle, }) => { @@ -731,9 +745,21 @@ export const PolygonEditor: React.FC = ({ useEffect(() => { const onGridMove = (event: GridEvent) => { const point = levelNode ? event.localPosition : event.position - const gridX = snapToHalf(point[0]) - const gridZ = snapToHalf(point[2]) - const newPosition: [number, number] = [gridX, gridZ] + const rawPoint: [number, number] = [point[0], point[2]] + const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])] + const newPosition = + dragState?.isDragging && resolvePlanPoint + ? resolvePlanPoint({ + rawPoint, + gridPoint, + mode: dragState.mode, + vertexIndex: dragState.vertexIndex, + edgeIndex: dragState.edgeIndex, + initialPosition: dragState.initialPosition, + initialPolygon: dragState.initialPolygon, + nativeEvent: event.nativeEvent, + }) + : gridPoint // Play snap sound when cursor moves to a new grid cell during drag if ( @@ -788,7 +814,7 @@ export const PolygonEditor: React.FC = ({ return () => { emitter.off('grid:move', onGridMove) } - }, [dragState, handleVertexDrag, levelNode, updatePreviewPolygon]) + }, [dragState, handleVertexDrag, levelNode, resolvePlanPoint, updatePreviewPolygon]) // Set up pointer up listener for ending drag useEffect(() => { diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index fe1982fa..4491c70f 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -20,6 +20,7 @@ import { WALL_JOIN_SNAP_RADIUS, type WallDraftSnapResult, type WallPlanPoint, + type WallSnapRadii, } from './wall-snap-geometry' // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so @@ -30,6 +31,7 @@ export { type WallDraftSnapKind, type WallDraftSnapResult, type WallPlanPoint, + type WallSnapRadii, } from './wall-snap-geometry' export const WALL_GRID_STEP = 0.5 @@ -345,6 +347,8 @@ type SnapWallDraftArgs = { * local-axis grid at `step`. */ gridSnap?: (point: WallPlanPoint) => WallPlanPoint + /** Optional magnetic snap radii. Omitted means wall tools keep their defaults. */ + snapRadii?: WallSnapRadii } export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult { @@ -357,13 +361,14 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn step: overrideStep, magnetic = true, gridSnap, + snapRadii, } = args // Discrete special points (corner / midpoint / crossing) are taken from the // raw cursor so an interim grid snap can't mask them. A corner always wins, // then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`. if (magnetic) { - const special = findWallSpecialPointSnap(point, walls, ignoreWallIds) + const special = findWallSpecialPointSnap(point, walls, ignoreWallIds, snapRadii) if (special) return special } @@ -377,7 +382,10 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn : snapPointToGrid(point, step) if (magnetic) { - const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds }) + const wallSnap = findWallSnapTarget(basePoint, walls, { + ignoreWallIds, + radius: snapRadii?.wall, + }) if (wallSnap) return { point: wallSnap, snap: 'wall' } } diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts index 4aadb80d..35829dcd 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts @@ -62,6 +62,13 @@ describe('findWallSpecialPointSnap', () => { // handled separately by findWallSnapTarget, not a special point. expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull() }) + + test('honors tighter per-call radii without changing defaults', () => { + const walls = [makeWall([0, 0], [4, 0])] + + expect(findWallSpecialPointSnap([0.34, 0], walls)?.snap).toBe('endpoint') + expect(findWallSpecialPointSnap([0.34, 0], walls, undefined, { endpoint: 0.3 })).toBeNull() + }) }) describe('findWallSnapTarget (edge / along-wall)', () => { @@ -76,4 +83,10 @@ describe('findWallSnapTarget (edge / along-wall)', () => { const walls = [makeWall([0, 0], [4, 0])] expect(findWallSnapTarget([1.2, 2], walls)).toBeNull() }) + + test('honors a tighter wall-body radius', () => { + const walls = [makeWall([0, 0], [4, 0])] + + expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull() + }) }) diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts index 049e7a11..b94b7000 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -15,6 +15,8 @@ export type WallPlanPoint = [number, number] /** Which kind of existing-geometry snap produced a drafted point. */ export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall' +export type WallSnapRadii = Partial> + export type WallDraftSnapResult = { point: WallPlanPoint /** @@ -119,9 +121,10 @@ export function findWallEndpointFromRaw( point: WallPlanPoint, walls: WallNode[], ignoreWallIds?: string[], + radius = WALL_ENDPOINT_SNAP_RADIUS, ): WallPlanPoint | null { const ignored = new Set(ignoreWallIds ?? []) - const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2 + const radiusSquared = radius ** 2 let best: WallPlanPoint | null = null let bestDistSq = Number.POSITIVE_INFINITY @@ -152,9 +155,10 @@ export function findWallMidpointFromRaw( point: WallPlanPoint, walls: WallNode[], ignoreWallIds?: string[], + radius = WALL_MIDPOINT_SNAP_RADIUS, ): WallPlanPoint | null { const ignored = new Set(ignoreWallIds ?? []) - const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2 + const radiusSquared = radius ** 2 let best: WallPlanPoint | null = null let bestDistSq = Number.POSITIVE_INFINITY @@ -202,10 +206,11 @@ export function findWallIntersectionFromRaw( point: WallPlanPoint, walls: WallNode[], ignoreWallIds?: string[], + radius = WALL_INTERSECTION_SNAP_RADIUS, ): WallPlanPoint | null { const ignored = new Set(ignoreWallIds ?? []) const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall)) - const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2 + const radiusSquared = radius ** 2 let best: WallPlanPoint | null = null let bestDistSq = Number.POSITIVE_INFINITY @@ -257,12 +262,13 @@ export function findWallSpecialPointSnap( point: WallPlanPoint, walls: WallNode[], ignoreWallIds?: string[], + radii?: WallSnapRadii, ): WallDraftSnapResult | null { - const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds) + const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds, radii?.endpoint) if (endpoint) return { point: endpoint, snap: 'endpoint' } - const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds) - const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds) + const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds, radii?.midpoint) + const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds, radii?.intersection) return nearestCandidate(point, [ midpoint && { point: midpoint, snap: 'midpoint' }, intersection && { point: intersection, snap: 'intersection' }, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5bc28f58..16a81e18 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -65,6 +65,7 @@ export { useFreshPlacementVisibility } from './components/tools/shared/fresh-pla // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { PolygonEditor, + type PolygonEditorPlanPointSnapContext, type PolygonEditorProps, } from './components/tools/shared/polygon-editor' export { @@ -108,6 +109,7 @@ export { type WallDraftSnapKind, type WallDraftSnapResult, type WallPlanPoint, + type WallSnapRadii, } from './components/tools/wall/wall-drafting' // `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the // existing `ViewerToolbarLeft` / `ViewerToolbarRight` exports — the @@ -172,6 +174,13 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action' // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). export { markToolCancelConsumed } from './hooks/use-keyboard' export { type Selection, useSelection } from './hooks/use-selection' +export { + CEILING_ALIGNMENT_THRESHOLD_M, + type CeilingPlanSnapInput, + type CeilingPlanSnapResult, + clearCeilingSnapFeedback, + resolveCeilingPlanPointSnap, +} from './lib/ceiling-plan-snap' export { EDITOR_LAYER } from './lib/constants' // Helper libs used by the kind-owned roof / stair / elevator panels. export { diff --git a/packages/editor/src/lib/ceiling-plan-snap.ts b/packages/editor/src/lib/ceiling-plan-snap.ts new file mode 100644 index 00000000..d5351cf2 --- /dev/null +++ b/packages/editor/src/lib/ceiling-plan-snap.ts @@ -0,0 +1,232 @@ +import { + type AlignmentAnchor, + type AlignmentGuide, + type AnyNode, + collectAlignmentAnchors, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + resolveAlignment, + resolveLevelId, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + getSegmentGridStep, + snapWallDraftPointDetailed, + type WallDraftSnapKind, + type WallPlanPoint, + type WallSnapRadii, +} from '../components/tools/wall/wall-drafting' +import useAlignmentGuides from '../store/use-alignment-guides' +import useEditor from '../store/use-editor' +import useWallSnapIndicator from '../store/use-wall-snap-indicator' + +const CEILING_SNAP_MOVING_ID = '__ceiling_snap__' +export const CEILING_ALIGNMENT_THRESHOLD_M = 0.08 +const CEILING_WALL_SNAP_RADII = { + endpoint: 0.38, + midpoint: 0.28, + intersection: 0.28, + wall: 0.18, +} satisfies WallSnapRadii +const WALL_SOURCE_MATCH_EPSILON = 0.035 + +export type CeilingPlanSnapInput = { + rawPoint: WallPlanPoint + fallbackPoint?: WallPlanPoint + levelId?: string | null + excludeId?: string | null + movingId?: string + nodes?: Readonly> + walls?: readonly WallNode[] + candidates?: readonly AlignmentAnchor[] + threshold?: number + altKey?: boolean + magnetic?: boolean + align?: boolean + step?: number + snapRadii?: WallSnapRadii +} + +export type CeilingPlanSnapResult = { + point: WallPlanPoint + wallSnap: WallDraftSnapKind | null + guides: AlignmentGuide[] + wallIds: string[] +} + +function getLevelWalls( + nodes: Readonly>, + levelId: string | null | undefined, + walls?: readonly WallNode[], +): WallNode[] { + const source = + walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall') + if (!levelId) return source.filter((wall) => wall.visible !== false) + + return source.filter( + (wall) => + wall.visible !== false && resolveLevelId(wall, nodes as Record) === levelId, + ) +} + +function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz +} + +function wallMidpoint(wall: WallNode): WallPlanPoint { + if (isCurvedWall(wall)) { + const frame = getWallCurveFrameAt(wall, 0.5) + return [frame.point.x, frame.point.y] + } + return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] +} + +function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-9) return distanceSquared(point, start) + + const t = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared), + ) + const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t] + return distanceSquared(point, projected) +} + +function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) { + if (!isCurvedWall(wall)) { + return distanceToSegmentSquared(point, wall.start, wall.end) + } + + const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3)) + let bestDistanceSquared = Number.POSITIVE_INFINITY + let previous = getWallCurveFrameAt(wall, 0).point + for (let index = 1; index <= sampleCount; index += 1) { + const current = getWallCurveFrameAt(wall, index / sampleCount).point + const distance = distanceToSegmentSquared( + point, + [previous.x, previous.y], + [current.x, current.y], + ) + bestDistanceSquared = Math.min(bestDistanceSquared, distance) + previous = current + } + return bestDistanceSquared +} + +function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) { + return walls + .map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) })) + .sort((a, b) => a.distance - b.distance) + .slice(0, count) + .map(({ id }) => id) +} + +function findSnapSourceWallIds( + point: WallPlanPoint, + kind: WallDraftSnapKind, + walls: readonly WallNode[], +): string[] { + const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2 + + if (kind === 'endpoint') { + const endpointMatches = walls.filter( + (wall) => + distanceSquared(point, wall.start) <= epsilonSquared || + distanceSquared(point, wall.end) <= epsilonSquared, + ) + if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id) + return closestWallIds(point, walls, 1) + } + + if (kind === 'midpoint') { + const midpointMatches = walls.filter( + (wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared, + ) + if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id) + return closestWallIds(point, walls, 1) + } + + if (kind === 'intersection') { + const crossingMatches = walls.filter( + (wall) => distanceToWallSquared(point, wall) <= epsilonSquared, + ) + if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2) + return closestWallIds(point, walls, 2) + } + + return closestWallIds(point, walls, 1) +} + +export function clearCeilingSnapFeedback() { + useAlignmentGuides.getState().clear() + useWallSnapIndicator.getState().clear() +} + +export function resolveCeilingPlanPointSnap(input: CeilingPlanSnapInput): CeilingPlanSnapResult { + const nodes = input.nodes ?? useScene.getState().nodes + const walls = getLevelWalls(nodes, input.levelId, input.walls) + const fallbackPoint = input.fallbackPoint + const magnetic = input.magnetic ?? useEditor.getState().magneticSnap + + const wallSnap = snapWallDraftPointDetailed({ + point: input.rawPoint, + walls, + step: input.step ?? getSegmentGridStep(), + magnetic, + snapRadii: input.snapRadii ?? CEILING_WALL_SNAP_RADII, + gridSnap: fallbackPoint ? () => fallbackPoint : undefined, + }) + + if (wallSnap.snap) { + const wallIds = findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls) + useWallSnapIndicator + .getState() + .set({ x: wallSnap.point[0], z: wallSnap.point[1], kind: wallSnap.snap, wallIds }) + useAlignmentGuides.getState().clear() + return { point: wallSnap.point, wallSnap: wallSnap.snap, guides: [], wallIds } + } + + useWallSnapIndicator.getState().clear() + + const basePoint = fallbackPoint ?? wallSnap.point + if (input.align === false || input.altKey) { + useAlignmentGuides.getState().clear() + return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } + } + + const movingId = input.movingId ?? CEILING_SNAP_MOVING_ID + const candidates = + input.candidates ?? + collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null) + + if (candidates.length === 0) { + useAlignmentGuides.getState().clear() + return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } + } + + const alignment = resolveAlignment({ + moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }], + candidates, + threshold: input.threshold ?? CEILING_ALIGNMENT_THRESHOLD_M, + }) + + useAlignmentGuides.getState().set(alignment.guides) + + if (!alignment.snap) { + return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] } + } + + return { + point: [basePoint[0] + alignment.snap.dx, basePoint[1] + alignment.snap.dz], + wallSnap: null, + guides: alignment.guides, + wallIds: [], + } +} diff --git a/packages/editor/src/store/use-wall-snap-indicator.ts b/packages/editor/src/store/use-wall-snap-indicator.ts index cd23e29c..9cdbd4a9 100644 --- a/packages/editor/src/store/use-wall-snap-indicator.ts +++ b/packages/editor/src/store/use-wall-snap-indicator.ts @@ -15,6 +15,8 @@ export type WallSnapPoint = { x: number z: number kind: WallSnapKind + /** Optional wall ids whose geometry produced this snap. */ + wallIds?: string[] } type WallSnapIndicatorState = { diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index 0ae77a9d..9093b7fe 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -1,7 +1,13 @@ 'use client' import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' -import { PolygonEditor, triggerSFX } from '@pascal-app/editor' +import { + clearCeilingSnapFeedback, + PolygonEditor, + type PolygonEditorPlanPointSnapContext, + resolveCeilingPlanPointSnap, + triggerSFX, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -36,9 +42,13 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = () => (ceiling && liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling), [ceiling, liveOverride], ) + const ceilingLevelId = effectiveCeiling + ? resolveLevelId(effectiveCeiling, useScene.getState().nodes) + : null const handlePolygonChange = useCallback( (newPolygon: Array<[number, number]>) => { + clearCeilingSnapFeedback() updateNode(ceilingId, { polygon: newPolygon }) setSelection({ selectedIds: [ceilingId] }) }, @@ -87,12 +97,18 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = (isDragging: boolean) => { if (!isDragging) { ownsPolygonPreviewRef.current = false + clearCeilingSnapFeedback() } setCeilingHandleHover(isDragging) }, [setCeilingHandleHover], ) + const handlePolygonEditorDragCommit = useCallback(() => { + triggerSFX('sfx:item-place') + clearCeilingSnapFeedback() + }, []) + const handlePolygonEditorDragStart = useCallback(() => { ownsPolygonPreviewRef.current = true triggerSFX('sfx:item-pick') @@ -102,8 +118,21 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = ownsPolygonPreviewRef.current = true }, []) + const resolvePolygonEditorPlanPoint = useCallback( + (context: PolygonEditorPlanPointSnapContext) => + resolveCeilingPlanPointSnap({ + rawPoint: context.rawPoint, + fallbackPoint: context.gridPoint, + levelId: ceilingLevelId, + excludeId: ceilingId, + altKey: context.nativeEvent?.altKey === true, + }).point, + [ceilingId, ceilingLevelId], + ) + useEffect(() => { return () => { + clearCeilingSnapFeedback() useLiveNodeOverrides.getState().clear(ceilingId) useScene.getState().markDirty(ceilingId) ownsPolygonPreviewRef.current = false @@ -121,18 +150,19 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = allowEdgeMove color="#d4d4d4" highlightConnectedHandles - levelId={resolveLevelId(effectiveCeiling, useScene.getState().nodes)} + levelId={ceilingLevelId ?? undefined} minVertices={3} onBeforeVertexDrag={handlePolygonEditorBeforeVertexDrag} - onDragStateChange={handleDragStateChange} - onDragCommit={() => triggerSFX('sfx:item-place')} + onDragCommit={handlePolygonEditorDragCommit} onDragStart={handlePolygonEditorDragStart} + onDragStateChange={handleDragStateChange} onEdgeHoverChange={handleHandleHoverChange} onMidpointHoverChange={handleHandleHoverChange} onPolygonChange={handlePolygonChange} onPolygonPreview={handlePolygonPreview} onVertexHoverChange={handleHandleHoverChange} polygon={effectiveCeiling.polygon} + resolvePlanPoint={resolvePolygonEditorPlanPoint} surfaceHeight={effectiveCeiling.height ?? 2.5} /> ) diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index 77648015..d70c560f 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -1,19 +1,13 @@ 'use client' -import { - collectAlignmentAnchors, - emitter, - type GridEvent, - type LevelNode, - resolveAlignment, - useScene, -} from '@pascal-app/core' +import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' import { CursorSphere, + clearCeilingSnapFeedback, EDITOR_LAYER, markToolCancelConsumed, + resolveCeilingPlanPointSnap, triggerSFX, - useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' @@ -33,8 +27,6 @@ import { CeilingNode } from './schema' const CEILING_HEIGHT = 2.52 const GRID_OFFSET = 0.02 -/** Figma-style alignment-snap threshold (meters), matching the move tools. */ -const ALIGNMENT_THRESHOLD_M = 0.08 function calculateSnapPoint( lastPoint: [number, number], @@ -93,10 +85,7 @@ export const CeilingTool: React.FC = () => { // draw isn't built with a stale preset's parameters. Unmount-only. useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), []) - // Clear alignment guides on unmount ONLY. The main drawing effect re-runs - // on every cursor move (cursorPosition is in its deps), so clearing guides - // in its cleanup would wipe the guide the instant after each move sets it. - useEffect(() => () => useAlignmentGuides.getState().clear(), []) + useEffect(() => () => clearCeilingSnapFeedback(), []) const verticalGeo = useMemo( () => @@ -115,44 +104,6 @@ export const CeilingTool: React.FC = () => { useEffect(() => { if (!currentLevelId) return - // Alignment candidates — anchors of every OTHER alignable object. The - // ceiling's own in-progress vertices are intentionally excluded (no - // self-alignment while drawing). - const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') - // Snap the drafted vertex onto another object's nearest real anchor and - // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped - // point: resolving against the grid point would only ever catch anchors - // that happen to sit on a grid line, so off-grid items (furniture, angled - // walls) would never surface a guide. The matched axis locks exactly to the - // candidate's coordinate; the other axis keeps its grid/ortho snap. Alt - // bypasses. - const alignPoint = ( - fallback: [number, number], - raw: [number, number], - bypass: boolean, - ): [number, number] => { - if (bypass || alignmentCandidates.length === 0) { - useAlignmentGuides.getState().clear() - return fallback - } - const ar = resolveAlignment({ - moving: [{ nodeId: '__ceiling-draft__', kind: 'corner', x: raw[0], z: raw[1] }], - candidates: alignmentCandidates, - threshold: ALIGNMENT_THRESHOLD_M, - }) - if (ar.guides.length === 0) { - useAlignmentGuides.getState().clear() - return fallback - } - useAlignmentGuides.getState().set(ar.guides) - let [x, z] = fallback - for (const guide of ar.guides) { - if (guide.axis === 'x') x = guide.coord - else z = guide.coord - } - return [x, z] - } - const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && gridCursorRef.current)) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] @@ -168,7 +119,12 @@ export const CeilingTool: React.FC = () => { shiftPressed.current || !lastPoint ? gridPosition : calculateSnapPoint(lastPoint, gridPosition) - const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true) + const displayPoint = resolveCeilingPlanPointSnap({ + rawPoint, + fallbackPoint: orthoPoint, + levelId: currentLevelId, + altKey: event.nativeEvent?.altKey === true, + }).point setSnappedCursorPosition(displayPoint) if ( points.length > 0 && @@ -199,7 +155,7 @@ export const CeilingTool: React.FC = () => { const ceilingId = commitCeilingDrawing(currentLevelId, points) setSelection({ selectedIds: [ceilingId] }) setPoints([]) - useAlignmentGuides.getState().clear() + clearCeilingSnapFeedback() } else { // Every non-closing vertex is a "start" tick; the closing click above // fires the structure-build (end) cue. @@ -214,14 +170,14 @@ export const CeilingTool: React.FC = () => { const ceilingId = commitCeilingDrawing(currentLevelId, points) setSelection({ selectedIds: [ceilingId] }) setPoints([]) - useAlignmentGuides.getState().clear() + clearCeilingSnapFeedback() } } const onCancel = () => { if (points.length > 0) markToolCancelConsumed() setPoints([]) - useAlignmentGuides.getState().clear() + clearCeilingSnapFeedback() } const onKeyDown = (e: KeyboardEvent) => { From e81dc63b28662f3e27ac4b484ae3153c42a1acf7 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 9 Jun 2026 19:54:35 -0400 Subject: [PATCH 05/15] feat: sync surface polygon editing --- .../renderers/floorplan-registry-layer.tsx | 8 +- .../src/components/editor/floorplan-panel.tsx | 32 ++- .../use-floorplan-background-placement.ts | 19 +- packages/editor/src/index.tsx | 14 + packages/editor/src/lib/ceiling-plan-snap.ts | 234 +---------------- packages/editor/src/lib/slab-plan-snap.ts | 25 ++ packages/editor/src/lib/surface-plan-snap.ts | 239 ++++++++++++++++++ .../src/ceiling/floorplan-affordances.ts | 39 ++- .../src/shared/polygon-vertex-affordance.ts | 84 +++++- packages/nodes/src/slab/boundary-editor.tsx | 31 ++- .../nodes/src/slab/floorplan-affordances.ts | 39 ++- packages/nodes/src/slab/tool.tsx | 70 +---- 12 files changed, 523 insertions(+), 311 deletions(-) create mode 100644 packages/editor/src/lib/slab-plan-snap.ts create mode 100644 packages/editor/src/lib/surface-plan-snap.ts diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 124bcd91..a6372283 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -19,7 +19,6 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { memo, @@ -31,6 +30,7 @@ import { useState, } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' +import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import useEditor from '../../../store/use-editor' import { useFloorplanRender } from '../floorplan-render-context' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' @@ -603,6 +603,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { } drag.session.commit() sfxEmitter.emit('sfx:structure-build') + clearSurfacePlanSnapFeedback() dragRef.current = null setActiveDragId(null) setRotationOverlay(null) @@ -654,6 +655,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { for (const id of drag.session.affectedIds) overrides.clear(id) } + clearSurfacePlanSnapFeedback() dragRef.current = null setActiveDragId(null) setRotationOverlay(null) @@ -672,7 +674,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // Affordances that publish Figma alignment guides during `apply` // (fence endpoint) leave them in the store on cancel — `canCommit` // (the pointer-up clear) never runs on a cancel. - useAlignmentGuides.getState().clear() + clearSurfacePlanSnapFeedback() // Drop any live overrides the session may have published. No-op // for affordances whose `apply()` writes straight to scene; the // override-routed sessions (wall endpoint, wall curve) rely on @@ -707,7 +709,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { dragRef.current = null } // Clear any alignment guide a session left behind on mid-drag unmount. - useAlignmentGuides.getState().clear() + clearSurfacePlanSnapFeedback() } }, []) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 8304e0d9..90a62df4 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -77,6 +77,7 @@ import { import { guideEmitter } from '../../lib/guide-events' import { sfxEmitter } from '../../lib/sfx-bus' import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' +import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { cn } from '../../lib/utils' import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap' import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor' @@ -8507,13 +8508,25 @@ export function FloorplanPanel() { // moves (the catch-all would otherwise swallow the move event). if (isPolygonBuildActive) { const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed - let snappedPoint = snapPolygonDraftPoint({ + const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], angleSnap, }) - if (angleSnap) useAlignmentGuides.getState().clear() - else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) + let snappedPoint = fallbackPoint + if (isSlabBuildActive) { + snappedPoint = resolveSlabPlanPointSnap({ + rawPoint: planPoint, + fallbackPoint, + levelId, + altKey: event.altKey, + align: !angleSnap, + }).point + } else if (angleSnap) { + useAlignmentGuides.getState().clear() + } else { + snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { bypass: event.altKey }) + } // Emit `grid:move` so the registry-driven slab tool also tracks // the cursor (its 3D preview needs it). @@ -8681,6 +8694,7 @@ export function FloorplanPanel() { isOpeningPlacementActive, isPolygonBuildActive, isRoofBuildActive, + isSlabBuildActive, isWallBuildActive, levelId, publishFloorplanNavigationPose, @@ -8939,6 +8953,7 @@ export function FloorplanPanel() { isOpeningPlacementActive, isPolygonBuildActive, isRoofBuildActive, + isSlabBuildActive, isWallBuildActive, isZoneBuildActive, levelId, @@ -9141,10 +9156,17 @@ export function FloorplanPanel() { if (isZoneBuildActive) { handleZonePlacementConfirm(fallbackPoint) } else { + const snappedPoint = resolveSlabPlanPointSnap({ + rawPoint: planPoint, + fallbackPoint, + levelId, + altKey: event.altKey, + align: !angleSnap, + }).point // Slab is registry-driven: forward the double-click so the 3D tool // commits the node (zone has no registry tool, so it commits locally). - emitFloorplanGridEvent('double-click', planPoint, event) - handleSlabPlacementConfirm(fallbackPoint) + emitFloorplanGridEvent('double-click', snappedPoint, event) + handleSlabPlacementConfirm(snappedPoint) } }, [ diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 6dd16c43..4293df6c 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -4,6 +4,7 @@ import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-ap import { type MouseEvent as ReactMouseEvent, useCallback } from 'react' import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' +import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { WALL_FINE_GRID_STEP, @@ -50,6 +51,7 @@ type UseFloorplanBackgroundPlacementArgs = { isOpeningPlacementActive: boolean isPolygonBuildActive: boolean isRoofBuildActive: boolean + isSlabBuildActive: boolean isWallBuildActive: boolean isZoneBuildActive: boolean levelId: string | null @@ -107,6 +109,7 @@ export function useFloorplanBackgroundPlacement({ isOpeningPlacementActive, isPolygonBuildActive, isRoofBuildActive, + isSlabBuildActive, isWallBuildActive, isZoneBuildActive, levelId, @@ -233,13 +236,22 @@ export function useFloorplanBackgroundPlacement({ // the 2D draft polygon invisible while the 3D tool builds fine). if (isPolygonBuildActive) { const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed - let snappedPoint = snapPolygonDraftPoint({ + const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], angleSnap, }) - if (!angleSnap) { - snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) + let snappedPoint = fallbackPoint + if (isSlabBuildActive) { + snappedPoint = resolveSlabPlanPointSnap({ + rawPoint: planPoint, + fallbackPoint, + levelId, + altKey: event.altKey, + align: !angleSnap, + }).point + } else if (!angleSnap) { + snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { bypass: event.altKey }) } // Emit the grid event so the registry-driven slab tool also @@ -328,6 +340,7 @@ export function useFloorplanBackgroundPlacement({ isOpeningPlacementActive, isPolygonBuildActive, isRoofBuildActive, + isSlabBuildActive, isWallBuildActive, isZoneBuildActive, levelId, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 16a81e18..874dea93 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -234,6 +234,13 @@ export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-dup export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' +export { + clearSlabSnapFeedback, + resolveSlabPlanPointSnap, + SLAB_ALIGNMENT_THRESHOLD_M, + type SlabPlanSnapInput, + type SlabPlanSnapResult, +} from './lib/slab-plan-snap' export { duplicateStairSubtree } from './lib/stair-duplication' export { getBuildingLevelsForLevel, @@ -243,6 +250,13 @@ export { resolveStairPlacementLevelId, resolveStairToLevelId, } from './lib/stair-levels' +export { + clearSurfacePlanSnapFeedback, + resolveSurfacePlanPointSnap, + SURFACE_ALIGNMENT_THRESHOLD_M, + type SurfacePlanSnapInput, + type SurfacePlanSnapResult, +} from './lib/surface-plan-snap' // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ // nodes` so they don't need their own copy / their own tailwind-merge // dependency. diff --git a/packages/editor/src/lib/ceiling-plan-snap.ts b/packages/editor/src/lib/ceiling-plan-snap.ts index d5351cf2..ee4447ed 100644 --- a/packages/editor/src/lib/ceiling-plan-snap.ts +++ b/packages/editor/src/lib/ceiling-plan-snap.ts @@ -1,232 +1,24 @@ import { - type AlignmentAnchor, - type AlignmentGuide, - type AnyNode, - collectAlignmentAnchors, - getWallCurveFrameAt, - getWallCurveLength, - isCurvedWall, - resolveAlignment, - resolveLevelId, - useScene, - type WallNode, -} from '@pascal-app/core' -import { - getSegmentGridStep, - snapWallDraftPointDetailed, - type WallDraftSnapKind, - type WallPlanPoint, - type WallSnapRadii, -} from '../components/tools/wall/wall-drafting' -import useAlignmentGuides from '../store/use-alignment-guides' -import useEditor from '../store/use-editor' -import useWallSnapIndicator from '../store/use-wall-snap-indicator' + clearSurfacePlanSnapFeedback, + resolveSurfacePlanPointSnap, + SURFACE_ALIGNMENT_THRESHOLD_M, + type SurfacePlanSnapInput, + type SurfacePlanSnapResult, +} from './surface-plan-snap' const CEILING_SNAP_MOVING_ID = '__ceiling_snap__' -export const CEILING_ALIGNMENT_THRESHOLD_M = 0.08 -const CEILING_WALL_SNAP_RADII = { - endpoint: 0.38, - midpoint: 0.28, - intersection: 0.28, - wall: 0.18, -} satisfies WallSnapRadii -const WALL_SOURCE_MATCH_EPSILON = 0.035 -export type CeilingPlanSnapInput = { - rawPoint: WallPlanPoint - fallbackPoint?: WallPlanPoint - levelId?: string | null - excludeId?: string | null - movingId?: string - nodes?: Readonly> - walls?: readonly WallNode[] - candidates?: readonly AlignmentAnchor[] - threshold?: number - altKey?: boolean - magnetic?: boolean - align?: boolean - step?: number - snapRadii?: WallSnapRadii -} - -export type CeilingPlanSnapResult = { - point: WallPlanPoint - wallSnap: WallDraftSnapKind | null - guides: AlignmentGuide[] - wallIds: string[] -} - -function getLevelWalls( - nodes: Readonly>, - levelId: string | null | undefined, - walls?: readonly WallNode[], -): WallNode[] { - const source = - walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall') - if (!levelId) return source.filter((wall) => wall.visible !== false) - - return source.filter( - (wall) => - wall.visible !== false && resolveLevelId(wall, nodes as Record) === levelId, - ) -} - -function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) { - const dx = a[0] - b[0] - const dz = a[1] - b[1] - return dx * dx + dz * dz -} - -function wallMidpoint(wall: WallNode): WallPlanPoint { - if (isCurvedWall(wall)) { - const frame = getWallCurveFrameAt(wall, 0.5) - return [frame.point.x, frame.point.y] - } - return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] -} - -function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const lengthSquared = dx * dx + dz * dz - if (lengthSquared < 1e-9) return distanceSquared(point, start) - - const t = Math.max( - 0, - Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared), - ) - const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t] - return distanceSquared(point, projected) -} - -function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) { - if (!isCurvedWall(wall)) { - return distanceToSegmentSquared(point, wall.start, wall.end) - } - - const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3)) - let bestDistanceSquared = Number.POSITIVE_INFINITY - let previous = getWallCurveFrameAt(wall, 0).point - for (let index = 1; index <= sampleCount; index += 1) { - const current = getWallCurveFrameAt(wall, index / sampleCount).point - const distance = distanceToSegmentSquared( - point, - [previous.x, previous.y], - [current.x, current.y], - ) - bestDistanceSquared = Math.min(bestDistanceSquared, distance) - previous = current - } - return bestDistanceSquared -} - -function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) { - return walls - .map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) })) - .sort((a, b) => a.distance - b.distance) - .slice(0, count) - .map(({ id }) => id) -} - -function findSnapSourceWallIds( - point: WallPlanPoint, - kind: WallDraftSnapKind, - walls: readonly WallNode[], -): string[] { - const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2 - - if (kind === 'endpoint') { - const endpointMatches = walls.filter( - (wall) => - distanceSquared(point, wall.start) <= epsilonSquared || - distanceSquared(point, wall.end) <= epsilonSquared, - ) - if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id) - return closestWallIds(point, walls, 1) - } - - if (kind === 'midpoint') { - const midpointMatches = walls.filter( - (wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared, - ) - if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id) - return closestWallIds(point, walls, 1) - } - - if (kind === 'intersection') { - const crossingMatches = walls.filter( - (wall) => distanceToWallSquared(point, wall) <= epsilonSquared, - ) - if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2) - return closestWallIds(point, walls, 2) - } - - return closestWallIds(point, walls, 1) -} +export const CEILING_ALIGNMENT_THRESHOLD_M = SURFACE_ALIGNMENT_THRESHOLD_M +export type CeilingPlanSnapInput = SurfacePlanSnapInput +export type CeilingPlanSnapResult = SurfacePlanSnapResult export function clearCeilingSnapFeedback() { - useAlignmentGuides.getState().clear() - useWallSnapIndicator.getState().clear() + clearSurfacePlanSnapFeedback() } export function resolveCeilingPlanPointSnap(input: CeilingPlanSnapInput): CeilingPlanSnapResult { - const nodes = input.nodes ?? useScene.getState().nodes - const walls = getLevelWalls(nodes, input.levelId, input.walls) - const fallbackPoint = input.fallbackPoint - const magnetic = input.magnetic ?? useEditor.getState().magneticSnap - - const wallSnap = snapWallDraftPointDetailed({ - point: input.rawPoint, - walls, - step: input.step ?? getSegmentGridStep(), - magnetic, - snapRadii: input.snapRadii ?? CEILING_WALL_SNAP_RADII, - gridSnap: fallbackPoint ? () => fallbackPoint : undefined, + return resolveSurfacePlanPointSnap({ + ...input, + movingId: input.movingId ?? CEILING_SNAP_MOVING_ID, }) - - if (wallSnap.snap) { - const wallIds = findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls) - useWallSnapIndicator - .getState() - .set({ x: wallSnap.point[0], z: wallSnap.point[1], kind: wallSnap.snap, wallIds }) - useAlignmentGuides.getState().clear() - return { point: wallSnap.point, wallSnap: wallSnap.snap, guides: [], wallIds } - } - - useWallSnapIndicator.getState().clear() - - const basePoint = fallbackPoint ?? wallSnap.point - if (input.align === false || input.altKey) { - useAlignmentGuides.getState().clear() - return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } - } - - const movingId = input.movingId ?? CEILING_SNAP_MOVING_ID - const candidates = - input.candidates ?? - collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null) - - if (candidates.length === 0) { - useAlignmentGuides.getState().clear() - return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } - } - - const alignment = resolveAlignment({ - moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }], - candidates, - threshold: input.threshold ?? CEILING_ALIGNMENT_THRESHOLD_M, - }) - - useAlignmentGuides.getState().set(alignment.guides) - - if (!alignment.snap) { - return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] } - } - - return { - point: [basePoint[0] + alignment.snap.dx, basePoint[1] + alignment.snap.dz], - wallSnap: null, - guides: alignment.guides, - wallIds: [], - } } diff --git a/packages/editor/src/lib/slab-plan-snap.ts b/packages/editor/src/lib/slab-plan-snap.ts new file mode 100644 index 00000000..3902e324 --- /dev/null +++ b/packages/editor/src/lib/slab-plan-snap.ts @@ -0,0 +1,25 @@ +import { + clearSurfacePlanSnapFeedback, + resolveSurfacePlanPointSnap, + SURFACE_ALIGNMENT_THRESHOLD_M, + type SurfacePlanSnapInput, + type SurfacePlanSnapResult, +} from './surface-plan-snap' + +const SLAB_SNAP_MOVING_ID = '__slab_snap__' + +export const SLAB_ALIGNMENT_THRESHOLD_M = SURFACE_ALIGNMENT_THRESHOLD_M +export type SlabPlanSnapInput = SurfacePlanSnapInput +export type SlabPlanSnapResult = SurfacePlanSnapResult + +export function clearSlabSnapFeedback() { + clearSurfacePlanSnapFeedback() +} + +export function resolveSlabPlanPointSnap(input: SlabPlanSnapInput): SlabPlanSnapResult { + return resolveSurfacePlanPointSnap({ + ...input, + highlightWalls: input.highlightWalls ?? false, + movingId: input.movingId ?? SLAB_SNAP_MOVING_ID, + }) +} diff --git a/packages/editor/src/lib/surface-plan-snap.ts b/packages/editor/src/lib/surface-plan-snap.ts new file mode 100644 index 00000000..95693425 --- /dev/null +++ b/packages/editor/src/lib/surface-plan-snap.ts @@ -0,0 +1,239 @@ +import { + type AlignmentAnchor, + type AlignmentGuide, + type AnyNode, + collectAlignmentAnchors, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + resolveAlignment, + resolveLevelId, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + getSegmentGridStep, + snapWallDraftPointDetailed, + type WallDraftSnapKind, + type WallPlanPoint, + type WallSnapRadii, +} from '../components/tools/wall/wall-drafting' +import useAlignmentGuides from '../store/use-alignment-guides' +import useEditor from '../store/use-editor' +import useWallSnapIndicator from '../store/use-wall-snap-indicator' + +const SURFACE_SNAP_MOVING_ID = '__surface_snap__' +export const SURFACE_ALIGNMENT_THRESHOLD_M = 0.08 +const SURFACE_WALL_SNAP_RADII = { + endpoint: 0.38, + midpoint: 0.28, + intersection: 0.28, + wall: 0.18, +} satisfies WallSnapRadii +const WALL_SOURCE_MATCH_EPSILON = 0.035 + +export type SurfacePlanSnapInput = { + rawPoint: WallPlanPoint + fallbackPoint?: WallPlanPoint + levelId?: string | null + excludeId?: string | null + movingId?: string + nodes?: Readonly> + walls?: readonly WallNode[] + candidates?: readonly AlignmentAnchor[] + threshold?: number + altKey?: boolean + magnetic?: boolean + align?: boolean + highlightWalls?: boolean + step?: number + snapRadii?: WallSnapRadii +} + +export type SurfacePlanSnapResult = { + point: WallPlanPoint + wallSnap: WallDraftSnapKind | null + guides: AlignmentGuide[] + wallIds: string[] +} + +function getLevelWalls( + nodes: Readonly>, + levelId: string | null | undefined, + walls?: readonly WallNode[], +): WallNode[] { + const source = + walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall') + if (!levelId) return source.filter((wall) => wall.visible !== false) + + return source.filter( + (wall) => + wall.visible !== false && resolveLevelId(wall, nodes as Record) === levelId, + ) +} + +function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz +} + +function wallMidpoint(wall: WallNode): WallPlanPoint { + if (isCurvedWall(wall)) { + const frame = getWallCurveFrameAt(wall, 0.5) + return [frame.point.x, frame.point.y] + } + return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] +} + +function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-9) return distanceSquared(point, start) + + const t = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared), + ) + const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t] + return distanceSquared(point, projected) +} + +function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) { + if (!isCurvedWall(wall)) { + return distanceToSegmentSquared(point, wall.start, wall.end) + } + + const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3)) + let bestDistanceSquared = Number.POSITIVE_INFINITY + let previous = getWallCurveFrameAt(wall, 0).point + for (let index = 1; index <= sampleCount; index += 1) { + const current = getWallCurveFrameAt(wall, index / sampleCount).point + const distance = distanceToSegmentSquared( + point, + [previous.x, previous.y], + [current.x, current.y], + ) + bestDistanceSquared = Math.min(bestDistanceSquared, distance) + previous = current + } + return bestDistanceSquared +} + +function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) { + return walls + .map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) })) + .sort((a, b) => a.distance - b.distance) + .slice(0, count) + .map(({ id }) => id) +} + +function findSnapSourceWallIds( + point: WallPlanPoint, + kind: WallDraftSnapKind, + walls: readonly WallNode[], +): string[] { + const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2 + + if (kind === 'endpoint') { + const endpointMatches = walls.filter( + (wall) => + distanceSquared(point, wall.start) <= epsilonSquared || + distanceSquared(point, wall.end) <= epsilonSquared, + ) + if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id) + return closestWallIds(point, walls, 1) + } + + if (kind === 'midpoint') { + const midpointMatches = walls.filter( + (wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared, + ) + if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id) + return closestWallIds(point, walls, 1) + } + + if (kind === 'intersection') { + const crossingMatches = walls.filter( + (wall) => distanceToWallSquared(point, wall) <= epsilonSquared, + ) + if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2) + return closestWallIds(point, walls, 2) + } + + return closestWallIds(point, walls, 1) +} + +export function clearSurfacePlanSnapFeedback() { + useAlignmentGuides.getState().clear() + useWallSnapIndicator.getState().clear() +} + +export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult { + const nodes = input.nodes ?? useScene.getState().nodes + const walls = getLevelWalls(nodes, input.levelId, input.walls) + const fallbackPoint = input.fallbackPoint + const magnetic = input.magnetic ?? useEditor.getState().magneticSnap + + const wallSnap = snapWallDraftPointDetailed({ + point: input.rawPoint, + walls, + step: input.step ?? getSegmentGridStep(), + magnetic, + snapRadii: input.snapRadii ?? SURFACE_WALL_SNAP_RADII, + gridSnap: fallbackPoint ? () => fallbackPoint : undefined, + }) + + if (wallSnap.snap) { + const wallIds = + input.highlightWalls === false + ? [] + : findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls) + useWallSnapIndicator.getState().set({ + x: wallSnap.point[0], + z: wallSnap.point[1], + kind: wallSnap.snap, + ...(wallIds.length > 0 ? { wallIds } : {}), + }) + useAlignmentGuides.getState().clear() + return { point: wallSnap.point, wallSnap: wallSnap.snap, guides: [], wallIds } + } + + useWallSnapIndicator.getState().clear() + + const basePoint = fallbackPoint ?? wallSnap.point + if (input.align === false || input.altKey) { + useAlignmentGuides.getState().clear() + return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } + } + + const movingId = input.movingId ?? SURFACE_SNAP_MOVING_ID + const candidates = + input.candidates ?? + collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null) + + if (candidates.length === 0) { + useAlignmentGuides.getState().clear() + return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } + } + + const alignment = resolveAlignment({ + moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }], + candidates, + threshold: input.threshold ?? SURFACE_ALIGNMENT_THRESHOLD_M, + }) + + useAlignmentGuides.getState().set(alignment.guides) + + if (!alignment.snap) { + return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] } + } + + return { + point: [basePoint[0] + alignment.snap.dx, basePoint[1] + alignment.snap.dz], + wallSnap: null, + guides: alignment.guides, + wallIds: [], + } +} diff --git a/packages/nodes/src/ceiling/floorplan-affordances.ts b/packages/nodes/src/ceiling/floorplan-affordances.ts index d280d2e7..176906cc 100644 --- a/packages/nodes/src/ceiling/floorplan-affordances.ts +++ b/packages/nodes/src/ceiling/floorplan-affordances.ts @@ -1,8 +1,10 @@ -import type { CeilingNode } from '@pascal-app/core' +import { type AnyNode, type CeilingNode, resolveLevelId } from '@pascal-app/core' +import { resolveCeilingPlanPointSnap } from '@pascal-app/editor' import { createPolygonAddVertexAffordance, createPolygonMoveEdgeAffordance, createPolygonVertexAffordance, + type PolygonAffordanceSnapContext, } from '../shared/polygon-vertex-affordance' /** @@ -11,6 +13,35 @@ import { * optional `holeIndex`. See `slab/floorplan-affordances.ts` for the * full contract. */ -export const ceilingMoveVertexAffordance = createPolygonVertexAffordance('ceiling') -export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance('ceiling') -export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance('ceiling') +const ceilingSnapOptions = { + resolvePlanPoint({ + node, + nodes, + rawPoint, + fallbackPoint, + modifiers, + }: PolygonAffordanceSnapContext) { + const sceneNodes = nodes as Record + return resolveCeilingPlanPointSnap({ + rawPoint, + fallbackPoint, + levelId: resolveLevelId(node, sceneNodes), + excludeId: node.id, + nodes: sceneNodes, + altKey: modifiers.altKey, + }).point + }, +} + +export const ceilingMoveVertexAffordance = createPolygonVertexAffordance( + 'ceiling', + ceilingSnapOptions, +) +export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance( + 'ceiling', + ceilingSnapOptions, +) +export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance( + 'ceiling', + ceilingSnapOptions, +) diff --git a/packages/nodes/src/shared/polygon-vertex-affordance.ts b/packages/nodes/src/shared/polygon-vertex-affordance.ts index 087104e1..cf2e803e 100644 --- a/packages/nodes/src/shared/polygon-vertex-affordance.ts +++ b/packages/nodes/src/shared/polygon-vertex-affordance.ts @@ -1,6 +1,8 @@ import { + type AnyNode, type AnyNodeId, type FloorplanAffordance, + type FloorplanAffordanceModifiers, type FloorplanAffordanceSession, useScene, } from '@pascal-app/core' @@ -41,6 +43,22 @@ export type EdgeDragPayload = { edgeIndex: number } +type PolygonAffordanceMode = 'move-vertex' | 'add-vertex' | 'move-edge' + +export type PolygonAffordanceSnapContext = { + node: N + nodes: Record + rawPoint: WallPlanPoint + fallbackPoint: WallPlanPoint + modifiers: FloorplanAffordanceModifiers + holeIndex?: number + mode: PolygonAffordanceMode +} + +type PolygonAffordanceOptions = { + resolvePlanPoint?: (context: PolygonAffordanceSnapContext) => WallPlanPoint +} + type PolygonShape = { polygon: ReadonlyArray holes?: ReadonlyArray> @@ -76,11 +94,19 @@ function buildRingPatch( return { holes: nextHoles } } +function resolveAffordancePlanPoint( + options: PolygonAffordanceOptions | undefined, + context: PolygonAffordanceSnapContext, +): WallPlanPoint { + return options?.resolvePlanPoint?.(context) ?? context.fallbackPoint +} + export function createPolygonVertexAffordance( kind: string, + options?: PolygonAffordanceOptions, ): FloorplanAffordance { return { - start({ node, payload }): FloorplanAffordanceSession { + start({ node, payload, nodes }): FloorplanAffordanceSession { const { vertexIndex, holeIndex } = payload as PolygonVertexPayload const originalRing = getRing(node, holeIndex) if (!originalRing) { @@ -96,9 +122,17 @@ export function createPolygonVertexAffordance i === vertexIndex ? [snapped[0], snapped[1]] : p, ) @@ -128,9 +162,10 @@ export function createPolygonVertexAffordance( kind: string, + options?: PolygonAffordanceOptions, ): FloorplanAffordance { return { - start({ node, payload }): FloorplanAffordanceSession { + start({ node, payload, nodes }): FloorplanAffordanceSession { const { edgeIndex, holeIndex } = payload as AddVertexPayload const originalRing = getRing(node, holeIndex) if (!originalRing) { @@ -171,9 +206,17 @@ export function createPolygonAddVertexAffordance i === newVertexIndex ? [snapped[0], snapped[1]] : p, ) @@ -204,9 +247,10 @@ export function createPolygonAddVertexAffordance( kind: string, + options?: PolygonAffordanceOptions, ): FloorplanAffordance { return { - start({ node, payload, initialPlanPoint }): FloorplanAffordanceSession { + start({ node, payload, initialPlanPoint, nodes }): FloorplanAffordanceSession { const { edgeIndex, holeIndex } = payload as EdgeDragPayload const originalRing = getRing(node, holeIndex) if (!originalRing) { @@ -254,17 +298,33 @@ export function createPolygonMoveEdgeAffordance { if (i === edgeStartIndex || i === edgeEndIndex) { - return [p[0] + normalX * projection, p[1] + normalY * projection] + return [p[0] + normalX * normalDistance, p[1] + normalY * normalDistance] } return [p[0], p[1]] as [number, number] }) diff --git a/packages/nodes/src/slab/boundary-editor.tsx b/packages/nodes/src/slab/boundary-editor.tsx index c651646a..dde74c82 100644 --- a/packages/nodes/src/slab/boundary-editor.tsx +++ b/packages/nodes/src/slab/boundary-editor.tsx @@ -1,7 +1,12 @@ 'use client' import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@pascal-app/core' -import { PolygonEditor } from '@pascal-app/editor' +import { + clearSlabSnapFeedback, + PolygonEditor, + type PolygonEditorPlanPointSnapContext, + resolveSlabPlanPointSnap, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect } from 'react' @@ -30,9 +35,11 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI const setSelection = useViewer((s) => s.setSelection) const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null + const slabLevelId = slab ? resolveLevelId(slab, useScene.getState().nodes) : null const handlePolygonChange = useCallback( (newPolygon: Array<[number, number]>) => { + clearSlabSnapFeedback() updateNode(slabId, { polygon: newPolygon }) setSelection({ selectedIds: [slabId] }) }, @@ -46,6 +53,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI polygon: preview.map(([x, z]) => [x, z] as [number, number]), }) } else { + clearSlabSnapFeedback() useLiveNodeOverrides.getState().clear(slabId) } markDirty(slabId) @@ -53,11 +61,28 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI [slabId, markDirty], ) + const handleDragCommit = useCallback(() => { + clearSlabSnapFeedback() + }, []) + + const resolvePolygonEditorPlanPoint = useCallback( + (context: PolygonEditorPlanPointSnapContext) => + resolveSlabPlanPointSnap({ + rawPoint: context.rawPoint, + fallbackPoint: context.gridPoint, + levelId: slabLevelId, + excludeId: slabId, + altKey: context.nativeEvent?.altKey === true, + }).point, + [slabId, slabLevelId], + ) + // Guarantee the override clears if the editor unmounts mid-drag // (selection change, mode switch) so the slab mesh doesn't get stuck // on a stale polygon. useEffect(() => { return () => { + clearSlabSnapFeedback() useLiveNodeOverrides.getState().clear(slabId) useScene.getState().markDirty(slabId) } @@ -69,11 +94,13 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI ) diff --git a/packages/nodes/src/slab/floorplan-affordances.ts b/packages/nodes/src/slab/floorplan-affordances.ts index 93793060..b7554530 100644 --- a/packages/nodes/src/slab/floorplan-affordances.ts +++ b/packages/nodes/src/slab/floorplan-affordances.ts @@ -1,8 +1,10 @@ -import type { SlabNode } from '@pascal-app/core' +import { type AnyNode, resolveLevelId, type SlabNode } from '@pascal-app/core' +import { resolveSlabPlanPointSnap } from '@pascal-app/editor' import { createPolygonAddVertexAffordance, createPolygonMoveEdgeAffordance, createPolygonVertexAffordance, + type PolygonAffordanceSnapContext, } from '../shared/polygon-vertex-affordance' /** @@ -19,6 +21,35 @@ import { * the slab is selected, every hole's handles appear at the same time. * Simpler model, no UX downside in practice. */ -export const slabMoveVertexAffordance = createPolygonVertexAffordance('slab') -export const slabAddVertexAffordance = createPolygonAddVertexAffordance('slab') -export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance('slab') +const slabSnapOptions = { + resolvePlanPoint({ + node, + nodes, + rawPoint, + fallbackPoint, + modifiers, + }: PolygonAffordanceSnapContext) { + const sceneNodes = nodes as Record + return resolveSlabPlanPointSnap({ + rawPoint, + fallbackPoint, + levelId: resolveLevelId(node, sceneNodes), + excludeId: node.id, + nodes: sceneNodes, + altKey: modifiers.altKey, + }).point + }, +} + +export const slabMoveVertexAffordance = createPolygonVertexAffordance( + 'slab', + slabSnapOptions, +) +export const slabAddVertexAffordance = createPolygonAddVertexAffordance( + 'slab', + slabSnapOptions, +) +export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance( + 'slab', + slabSnapOptions, +) diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index 1121c6d4..1f734cab 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -1,19 +1,13 @@ 'use client' -import { - collectAlignmentAnchors, - emitter, - type GridEvent, - type LevelNode, - resolveAlignment, - useScene, -} from '@pascal-app/core' +import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' import { CursorSphere, + clearSlabSnapFeedback, EDITOR_LAYER, markToolCancelConsumed, + resolveSlabPlanPointSnap, triggerSFX, - useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' @@ -34,8 +28,6 @@ import { SlabNode } from './schema' */ const Y_OFFSET = 0.02 -/** Figma-style alignment-snap threshold (meters), matching the move tools. */ -const ALIGNMENT_THRESHOLD_M = 0.08 function calculateSnapPoint( lastPoint: [number, number], @@ -90,52 +82,11 @@ export const SlabTool: React.FC = () => { // isn't built with a stale preset's parameters. Unmount-only. useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), []) - // Clear alignment guides on unmount ONLY. The main drawing effect re-runs - // on every cursor move (cursorPosition is in its deps), so clearing guides - // in its cleanup would wipe the guide the instant after each move sets it. - useEffect(() => () => useAlignmentGuides.getState().clear(), []) + useEffect(() => () => clearSlabSnapFeedback(), []) useEffect(() => { if (!currentLevelId) return - // Alignment candidates — anchors of every OTHER alignable object. The - // slab's own in-progress vertices are intentionally excluded (no - // self-alignment while drawing). - const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') - // Snap the drafted vertex onto another object's nearest real anchor and - // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped - // point: resolving against the grid point would only ever catch anchors - // that happen to sit on a grid line, so off-grid items (furniture, angled - // walls) would never surface a guide. The matched axis locks exactly to the - // candidate's coordinate; the other axis keeps its grid/ortho snap. Alt - // bypasses. - const alignPoint = ( - fallback: [number, number], - raw: [number, number], - bypass: boolean, - ): [number, number] => { - if (bypass || alignmentCandidates.length === 0) { - useAlignmentGuides.getState().clear() - return fallback - } - const ar = resolveAlignment({ - moving: [{ nodeId: '__slab-draft__', kind: 'corner', x: raw[0], z: raw[1] }], - candidates: alignmentCandidates, - threshold: ALIGNMENT_THRESHOLD_M, - }) - if (ar.guides.length === 0) { - useAlignmentGuides.getState().clear() - return fallback - } - useAlignmentGuides.getState().set(ar.guides) - let [x, z] = fallback - for (const guide of ar.guides) { - if (guide.axis === 'x') x = guide.coord - else z = guide.coord - } - return [x, z] - } - const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] @@ -149,7 +100,12 @@ export const SlabTool: React.FC = () => { shiftPressed.current || !lastPoint ? gridPosition : calculateSnapPoint(lastPoint, gridPosition) - const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true) + const displayPoint = resolveSlabPlanPointSnap({ + rawPoint, + fallbackPoint: orthoPoint, + levelId: currentLevelId, + altKey: event.nativeEvent?.altKey === true, + }).point setSnappedCursorPosition(displayPoint) if ( points.length > 0 && @@ -176,7 +132,7 @@ export const SlabTool: React.FC = () => { const slabId = commitSlabDrawing(currentLevelId, points) setSelection({ selectedIds: [slabId] }) setPoints([]) - useAlignmentGuides.getState().clear() + clearSlabSnapFeedback() } else { // Every non-closing vertex is a "start" tick; the closing click above // fires the structure-build (end) cue. @@ -191,14 +147,14 @@ export const SlabTool: React.FC = () => { const slabId = commitSlabDrawing(currentLevelId, points) setSelection({ selectedIds: [slabId] }) setPoints([]) - useAlignmentGuides.getState().clear() + clearSlabSnapFeedback() } } const onCancel = () => { if (points.length > 0) markToolCancelConsumed() setPoints([]) - useAlignmentGuides.getState().clear() + clearSlabSnapFeedback() } const onKeyDown = (e: KeyboardEvent) => { From eb00f9f4771e1fcfd04473828908f091585438a6 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 9 Jun 2026 19:54:42 -0400 Subject: [PATCH 06/15] fix: harden editor scene loading --- .../editor/src/components/editor/index.tsx | 14 ++++ packages/viewer/src/lib/materials.ts | 2 +- packages/viewer/src/store/use-viewer.ts | 84 +++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 88e96497..6433940a 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -72,6 +72,7 @@ const PAINT_CURSOR_BADGE_COLOR = '#818cf8' const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8' const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14 +const SCENE_READY_FALLBACK_MS = 8000 const EDITOR_HOVER_STYLES: HoverStyles = { default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true }, delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false }, @@ -1059,6 +1060,19 @@ export default function Editor({ setIsViewerSceneReady(ready) }, []) + useEffect(() => { + if (isLoading || isSceneLoading || !hasLoadedInitialScene || isViewerSceneReady) return + + const timer = window.setTimeout(() => { + console.warn('[editor] viewer scene readiness timed out; showing editor shell anyway', { + sceneReadyKey, + }) + setIsViewerSceneReady(true) + }, SCENE_READY_FALLBACK_MS) + + return () => window.clearTimeout(timer) + }, [hasLoadedInitialScene, isLoading, isSceneLoading, isViewerSceneReady, sceneReadyKey]) + const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId) diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 9feadd5f..090971fa 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -71,7 +71,7 @@ export function resolveSurfaceColor( // The active scene theme may tint individual roles (e.g. Mediterranean's blue // roof); fall back to the chosen colour preset's palette when it doesn't. const tints = sceneThemeId ? getSceneTheme(sceneThemeId).clayTints : undefined - return tints?.[role] ?? PRESET_PALETTES[preset][role] + return tints?.[role] ?? (PRESET_PALETTES[preset] ?? CLAY_PALETTE)[role] } // DoubleSide on any NodeMaterial inside the MRT scenePass (SSGI's output / diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index c1da8e44..11cc67e3 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -7,6 +7,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import type { EdgeMode } from '../lib/edge-style' import type { ColorPreset, RenderShading } from '../lib/materials' +import { SCENE_THEME_IDS } from '../lib/scene-themes' export type RenderContext = 'editor' | 'viewer' @@ -114,6 +115,85 @@ type ViewerState = { setInputDragging: (dragging: boolean) => void } +type PersistedViewerState = Partial< + Pick< + ViewerState, + | 'cameraMode' + | 'sceneTheme' + | 'shadingByContext' + | 'textures' + | 'colorPreset' + | 'edges' + | 'shadows' + | 'unit' + | 'levelMode' + | 'wallMode' + | 'projectPreferences' + > +> + +const CAMERA_MODES = ['perspective', 'orthographic'] as const +const RENDER_SHADINGS = ['solid', 'rendered'] as const +const COLOR_PRESETS = ['clay', 'white', 'mono', 'blueprint'] as const +const EDGE_MODES = ['off', 'soft', 'strong'] as const +const UNITS = ['metric', 'imperial'] as const +const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const +const WALL_MODES = ['up', 'cutaway', 'down'] as const + +function pickString(value: unknown, allowed: readonly T[], fallback: T): T { + return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback +} + +function normalizeShadingByContext(value: unknown): ViewerState['shadingByContext'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + + const next: ViewerState['shadingByContext'] = {} + for (const [context, shading] of Object.entries(value)) { + if (context !== 'editor' && context !== 'viewer') continue + next[context] = pickString(shading, RENDER_SHADINGS, 'rendered') + } + return next +} + +function normalizeProjectPreferences(value: unknown): ViewerState['projectPreferences'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + + const next: ViewerState['projectPreferences'] = {} + for (const [projectId, preferences] of Object.entries(value)) { + if (!preferences || typeof preferences !== 'object' || Array.isArray(preferences)) continue + const record = preferences as Record + next[projectId] = { + ...(typeof record.showScans === 'boolean' ? { showScans: record.showScans } : {}), + ...(typeof record.showGuides === 'boolean' ? { showGuides: record.showGuides } : {}), + ...(typeof record.showGrid === 'boolean' ? { showGrid: record.showGrid } : {}), + } + } + return next +} + +function normalizePersistedViewerState(value: unknown): PersistedViewerState { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + const state = value as Record + + return { + cameraMode: pickString( + state.cameraMode, + CAMERA_MODES, + 'perspective', + ), + sceneTheme: pickString(state.sceneTheme, SCENE_THEME_IDS, 'studio'), + shadingByContext: normalizeShadingByContext(state.shadingByContext), + textures: typeof state.textures === 'boolean' ? state.textures : true, + colorPreset: pickString(state.colorPreset, COLOR_PRESETS, 'clay'), + edges: pickString(state.edges, EDGE_MODES, 'soft'), + shadows: typeof state.shadows === 'boolean' ? state.shadows : true, + unit: pickString(state.unit, UNITS, 'metric'), + levelMode: pickString(state.levelMode, LEVEL_MODES, 'stacked'), + wallMode: pickString(state.wallMode, WALL_MODES, 'up'), + projectPreferences: normalizeProjectPreferences(state.projectPreferences), + } +} + const useViewer = create()( persist( (set) => ({ @@ -267,6 +347,10 @@ const useViewer = create()( }), { name: 'viewer-preferences', + merge: (persistedState, currentState) => ({ + ...currentState, + ...normalizePersistedViewerState(persistedState), + }), partialize: (state) => ({ cameraMode: state.cameraMode, sceneTheme: state.sceneTheme, From 478f0910f72baca746e7a09b7d39e7f78e86e60f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 9 Jun 2026 19:55:53 -0400 Subject: [PATCH 07/15] feat(editor): roof features in standalone Build tab + thumbnail flag fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry: getRoofAccessoryKinds() enumerates kinds declaring the roofAccessory capability, in deterministic builtin-list order (mirrors getSelectableKinds); exported from the registry barrel. - apps/editor build-tab: a "Features" group under the Roof tile, discovered from the registry (no DB) and activating each kind's roof-attach tool — parity with the community editor's roof features. - site-boundary-editor: hide the flag handle group around thumbnail captures (they render on SCENE_LAYER so the thumbnail camera can't layer-filter them), matching handle-arrow.tsx — keeps flags out of preset/snapshot thumbnails. Co-Authored-By: Claude Fable 5 --- apps/editor/components/build-tab.tsx | 92 ++++++++++++++++++- packages/core/src/registry/index.ts | 1 + packages/core/src/registry/registry.ts | 22 +++++ .../tools/site/site-boundary-editor.tsx | 72 ++++++++++----- 4 files changed, 162 insertions(+), 25 deletions(-) diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 8d25a5eb..330628c3 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,8 +1,9 @@ 'use client' +import { getRoofAccessoryKinds, nodeRegistry } from '@pascal-app/core' import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' import Image from 'next/image' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Tooltip, TooltipContent, @@ -79,6 +80,27 @@ function activatePaintMode(): void { ed.setMode('material-paint') } +type RoofFeature = { kind: string; label: string; iconSrc: string } + +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 + * `presentation`; non-url icons fall back to the roof icon. + */ +function activateRoofFeatureTool(kind: string): void { + const ed = useEditor.getState() + ed.setPhase('structure') + ed.setStructureLayer('elements') + ed.setCatalogCategory(null) + ed.setMode('build') + ed.setTool(kind as Parameters[0]) +} + /** * Build tab for the open-source standalone editor — a preset-less replica of * the community Build sidebar. Clicking a type activates its raw tool, drawn @@ -88,11 +110,27 @@ function activatePaintMode(): void { export function BuildTab() { const activeTool = useEditor((s) => s.tool) const mode = useEditor((s) => s.mode) + // Which build tile's panel is showing. Roof is the only tile with a panel + // (its Features group); others arm a tool and show nothing below. + const [selectedTypeId, setSelectedTypeId] = useState(null) + + // 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().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 isTypeActive = (type: BuildType) => - type.mode === 'material-paint' - ? mode === 'material-paint' - : mode === 'build' && activeTool === type.kind + type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id const handleTypeClick = useCallback((type: BuildType) => { if (type.mode === 'material-paint') { @@ -100,6 +138,7 @@ export function BuildTab() { } else if (type.kind) { activateBuildTool(type.kind) } + setSelectedTypeId(type.id) }, []) // On open, land on the first build tool — parity with the community Build @@ -160,6 +199,51 @@ export function BuildTab() {
+ ) : selectedTypeId === 'roof' && roofFeatures.length > 0 ? ( +
+
Features
+ +
+ {roofFeatures.map((feature) => { + const active = mode === 'build' && activeTool === feature.kind + return ( + + + + + + {feature.label} + + + ) + })} +
+
+
) : null} ) diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 8dcfc58f..94bb862a 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -17,6 +17,7 @@ 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 051176fc..658636f3 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -114,6 +114,28 @@ 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/editor/src/components/tools/site/site-boundary-editor.tsx b/packages/editor/src/components/tools/site/site-boundary-editor.tsx index 41da633e..a763d9d4 100644 --- a/packages/editor/src/components/tools/site/site-boundary-editor.tsx +++ b/packages/editor/src/components/tools/site/site-boundary-editor.tsx @@ -3,7 +3,15 @@ import { SCENE_LAYER } from '@pascal-app/viewer' import { useGLTF } from '@react-three/drei/core/Gltf' import { useFrame } from '@react-three/fiber' import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Color, CylinderGeometry, DoubleSide, type Mesh, type Object3D, RingGeometry } from 'three' +import { + Color, + CylinderGeometry, + DoubleSide, + type Group, + type Mesh, + type Object3D, + RingGeometry, +} from 'three' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -407,6 +415,26 @@ export const SiteBoundaryEditor: React.FC = () => { } }, [isSiteEditing, siteId]) + // The flag models render on SCENE_LAYER (scene-depth occlusion), so unlike + // EDITOR_LAYER affordances the thumbnail camera can't filter them — hide + // them around captures (preset/snapshot/auto-save thumbnails), same as + // `handle-arrow.tsx`. + const handlesRootRef = useRef(null) + useEffect(() => { + const hideForCapture = () => { + if (handlesRootRef.current) handlesRootRef.current.visible = false + } + const restoreAfterCapture = () => { + if (handlesRootRef.current) handlesRootRef.current.visible = true + } + emitter.on('thumbnail:before-capture', hideForCapture) + emitter.on('thumbnail:after-capture', restoreAfterCapture) + return () => { + emitter.off('thumbnail:before-capture', hideForCapture) + emitter.off('thumbnail:after-capture', restoreAfterCapture) + } + }, []) + const activateSiteEditing = useCallback(() => { isDraggingSiteBoundaryRef.current = true setIsDraggingSiteBoundary(true) @@ -483,25 +511,27 @@ export const SiteBoundaryEditor: React.FC = () => { if (!showSiteHandles) return null return ( - { - sfxEmitter.emit('sfx:item-place') - exitSiteEditing() - }} - onDragStart={() => sfxEmitter.emit('sfx:item-pick')} - onDragStateChange={handleSiteBoundaryDragChange} - onMidpointHoverChange={setHoveredMidpoint} - onPolygonChange={handlePolygonChange} - onPolygonPreview={handlePolygonPreview} - onVertexHoverChange={setHoveredVertex} - polygon={displayPolygon} - renderMidpointHandle={renderSiteFlagMidpoint} - renderVertexHandle={renderSiteFlagVertex} - showBorderLine={isSiteBoundaryHighlighted} - showMidpointHandles={showSiteHandles} - /> + + { + sfxEmitter.emit('sfx:item-place') + exitSiteEditing() + }} + onDragStart={() => sfxEmitter.emit('sfx:item-pick')} + onDragStateChange={handleSiteBoundaryDragChange} + onMidpointHoverChange={setHoveredMidpoint} + onPolygonChange={handlePolygonChange} + onPolygonPreview={handlePolygonPreview} + onVertexHoverChange={setHoveredVertex} + polygon={displayPolygon} + renderMidpointHandle={renderSiteFlagMidpoint} + renderVertexHandle={renderSiteFlagVertex} + showBorderLine={isSiteBoundaryHighlighted} + showMidpointHandles={showSiteHandles} + /> + ) } From 1487328ec79c342a009c93c81d832f5af2151c16 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 10 Jun 2026 00:14:38 -0400 Subject: [PATCH 08/15] fix: never drop walkthrough player below the site ground plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site ground collider was sized to the scene footprint with a 30 m minimum — exactly the default site polygon, so one step past the site boundary left nothing under the character controller and the player fell into the void. The ground collider now extends 2 km (still a single BVH box) so the ground plane acts unbounded, and a respawn net in the first-person frame loop recovers the controller if it ever ends up below every collider (e.g. scenes with no site node), preferring the live spawn node over the mount-time start position. Co-Authored-By: Claude Fable 5 --- .../editor/first-person-controls.tsx | 24 +++++++++++++++++++ .../first-person/build-collider-world.test.ts | 9 ++++--- .../first-person/build-collider-world.ts | 11 +++++---- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index e1793c5d..0bcd06b3 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -78,6 +78,7 @@ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 +const VOID_FALL_RESPAWN_DEPTH = 12 const keyboardMap = [ { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, @@ -1217,6 +1218,29 @@ export const FirstPersonControls = () => { if (!controllerRef.current?.group) return const group = controllerRef.current.group + + // The site ground collider is effectively unbounded, but scenes without a + // site node only have finite fallback floors — if the controller still ends + // up below every collider it can never land, so put it back at the spawn. + // Prefer the live spawn node over the mount-time start position so a spawn + // moved mid-walkthrough doesn't respawn the player at stale coordinates. + const worldBounds = worldRef.current?.bounds + if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) { + const respawnPosition = placedSpawn + ? [ + placedSpawn.position[0], + placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE, + placedSpawn.position[2], + ] + : controllerStart?.position + if (respawnPosition) { + group.position.set(respawnPosition[0]!, respawnPosition[1]!, respawnPosition[2]!) + controllerRef.current.resetLinVel() + ridingElevatorRef.current = null + setElevatorRideLocked(false) + } + } + group.rotation.y = 0 camera.position.copy(group.position).add(cameraOffset) cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index c76d50f1..2a5d2ae1 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -141,9 +141,12 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { // Ground slab sits just below the site ground plane (y = 0). expect(world?.bounds?.min.y).toBeCloseTo(-0.08) expect(world?.bounds?.max.y).toBeCloseTo(0) - // Default site footprint falls back to the 30 m minimum size. - expect(world?.bounds?.min.x).toBeCloseTo(-15) - expect(world?.bounds?.max.x).toBeCloseTo(15) + // The ground collider extends far past the site polygon so stepping out of + // the site boundary never drops the player below the ground plane. + expect(world?.bounds?.min.x).toBeCloseTo(-1000) + expect(world?.bounds?.max.x).toBeCloseTo(1000) + expect(world?.bounds?.min.z).toBeCloseTo(-1000) + expect(world?.bounds?.max.z).toBeCloseTo(1000) world?.dispose() }) }) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 666e3de9..0e439eaa 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -27,6 +27,7 @@ const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85 const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08 const LEVEL_FALLBACK_FLOOR_PADDING = 2 const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30 +const SITE_GROUND_COLLIDER_MIN_SIZE = 2000 export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT @@ -129,8 +130,10 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) { // a dedicated collider, a spawn on the bare ground (no slab, or not parented to // a level that triggers the per-level fallback) has no floor to stand on and the // walkthrough player falls through. Derive a thin ground slab from node data (not -// the rendered mesh) so it exists regardless of geometry-mount timing, sized to -// cover the whole scene footprint at the site's ground plane. +// the rendered mesh) so it exists regardless of geometry-mount timing. The slab +// is effectively unbounded (not sized to the site polygon): the ground plane must +// keep holding the player up even after they step past the site boundary, +// otherwise they fall below the ground plane into the void. function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) { if (site.visible === false) return null @@ -142,11 +145,11 @@ function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) { const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0] const width = Math.max( boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2, - LEVEL_FALLBACK_FLOOR_MIN_SIZE, + SITE_GROUND_COLLIDER_MIN_SIZE, ) const depth = Math.max( boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2, - LEVEL_FALLBACK_FLOOR_MIN_SIZE, + SITE_GROUND_COLLIDER_MIN_SIZE, ) const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth) From bdfee058bdcbfc93a95e9f8f79d7e9636789a76d Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 10 Jun 2026 00:39:13 -0400 Subject: [PATCH 09/15] feat: doors and windows on roof-segment wall faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Openings now host on the walls a roof segment generates — the base walls under the roof and the coplanar gable/shed/gambrel end faces, so a window can sit in a gable pediment. - core: roof-segment-walls.ts models the four vertical faces as 2D frames (u along face, v height) with convex profile polygons that mirror the wall volume getRoofSegmentBrushes builds; rect-in-profile clamping and anchored resize limits via half-plane algebra. - schemas: optional roofSegmentId on door/window; position is the segment-local wall mid-plane center, rotation[1] the face yaw. - cut: reuses capabilities.roofAccessory.buildCut; new cutScope: 'wall' subtracts from the wall brush only. cascadesViaHostSegment keeps the roof-merge loop from consuming door/window dirty marks (their own systems cascade via parentId). - tools: roof:* handlers in door/window tool + move-tool (the Build-tab preset path), with roofSegmentId cleared/restored across every roof<->wall re-anchor and revert; shared hit resolver normalizes normals through world space (merged mesh vs painted segment frames). - fix: RoofSystem no longer rebuilds per-segment CSG in accessory-reveal mode — the uncut rebuild used to draw over the merged shell's fresh opening until deselect. - fix: the walkthrough collider world now prunes by renderer-effective visibility; stale uncut segment CSG inside the hidden segments-wrapper blocked the player at openings the merged shell had cut through. Known gap: painted segments render per-segment CSG without accessory cuts (pre-existing, also affects skylight/dormer). Twice Codex-reviewed; details in private-editor plans/editor-roof-wall-openings.md. Co-Authored-By: Claude Fable 5 --- apps/editor/components/build-tab.tsx | 21 +- packages/core/src/registry/types.ts | 17 + packages/core/src/schema/index.ts | 11 + packages/core/src/schema/nodes/door.ts | 5 + .../src/schema/nodes/roof-segment-walls.ts | 424 ++++++++++++++++++ packages/core/src/schema/nodes/window.ts | 5 + .../first-person/build-collider-world.test.ts | 30 ++ .../first-person/build-collider-world.ts | 26 +- packages/nodes/src/door/definition.ts | 33 +- packages/nodes/src/door/floorplan-move.ts | 25 +- packages/nodes/src/door/move-tool.tsx | 203 ++++++++- packages/nodes/src/door/renderer.tsx | 27 +- packages/nodes/src/door/tool.tsx | 187 +++++++- .../nodes/src/shared/roof-opening-host.ts | 108 +++++ packages/nodes/src/shared/roof-wall-hit.ts | 152 +++++++ .../nodes/src/shared/roof-wall-opening-cut.ts | 42 ++ packages/nodes/src/window/definition.ts | 28 +- packages/nodes/src/window/floorplan-move.ts | 20 +- packages/nodes/src/window/move-tool.tsx | 209 ++++++++- packages/nodes/src/window/renderer.tsx | 27 +- packages/nodes/src/window/tool.tsx | 181 +++++++- .../viewer/src/systems/roof/roof-system.tsx | 38 +- 22 files changed, 1771 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/schema/nodes/roof-segment-walls.ts create mode 100644 packages/nodes/src/shared/roof-opening-host.ts create mode 100644 packages/nodes/src/shared/roof-wall-hit.ts create mode 100644 packages/nodes/src/shared/roof-wall-opening-cut.ts diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 330628c3..80e5144b 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -118,14 +118,19 @@ export function BuildTab() { // app bootstrap, so enumerating earlier would race it and see no kinds. const roofFeatures = useMemo( () => - getRoofAccessoryKinds().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, - } - }), + 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, + } + }), [], ) diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 6f57ff0b..0ccf97a9 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1205,6 +1205,23 @@ export type PaintEffectiveMaterialArgs = { */ export type RoofAccessoryConfig = { buildCut?: (node: AnyNode, hostSegment: AnyNode) => BufferGeometry | null + /** + * Which segment brushes `buildCut` subtracts from. Wall-face openings + * (door / window) cut only the wall brush — subtracting the same box + * from the shin / deck slabs is pointless work and creates tangential + * / coplanar CSG cases near the gable and shed slopes. Defaults to + * all three (skylight / dormer genuinely poke through the deck). + */ + 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). + */ + cascadesViaHostSegment?: boolean } /** diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 777f3456..5d5c5a50 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -108,6 +108,17 @@ export { RoofSegmentNode, RoofType, } from './nodes/roof-segment' +export type { RoofSegmentWallFace, RoofWallFaceId } from './nodes/roof-segment-walls' +export { + clampRectToRoofWallFace, + getMaxRoofRectHeightFromAnchor, + getMaxRoofRectWidthFromAnchor, + getRoofSegmentWallFace, + getRoofSegmentWallFaces, + getRoofWallFaceIdFromYaw, + roofWallFaceLocalToSegment, + segmentPointToRoofWallFace, +} from './nodes/roof-segment-walls' export { ScanNode } from './nodes/scan' export { ShelfNode } from './nodes/shelf' export { SiteNode } from './nodes/site' diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index b64938c7..32bca4df 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -46,6 +46,11 @@ export const DoorNode = BaseNode.extend({ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), side: z.enum(['front', 'back']).optional(), wallId: z.string().optional(), + // Alternative host: a roof-segment's generated wall face (base wall + // under the roof or a coplanar gable end). When set, `position` is the + // opening center in SEGMENT-LOCAL coords on the outer wall plane and + // `rotation[1]` is the face yaw — see `roof-segment-walls.ts`. + roofSegmentId: z.string().optional(), // Overall dimensions width: z.number().default(0.9), diff --git a/packages/core/src/schema/nodes/roof-segment-walls.ts b/packages/core/src/schema/nodes/roof-segment-walls.ts new file mode 100644 index 00000000..54414ccf --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -0,0 +1,424 @@ +import type { RoofSegmentNode } from './roof-segment' +import { getSegmentSlopeFrame } from './roof-segment' + +/** + * Wall-face math for roof segments — the vertical surfaces a wall-mounted + * opening (door / window) can attach to. A segment's generated volume has + * four vertical faces; on gable-family roofs the end faces extend past the + * eave line into the gable (rect + triangle/pentagon, coplanar with the + * base wall). These helpers describe each face as a 2D frame + * (`u` along the face, `v` height above the segment base) plus the + * placeable profile polygon, so placement tools, renderers, and CSG cut + * builders all share one definition of "the wall under the roof". + * + * The numbers MUST mirror the outer wall volume built by + * `getRoofSegmentBrushes` in the viewer's roof system + * (`getVol(wallThickness / 2, 0, 0, …)`): the volume is the segment + * footprint extended outward by `wallThickness / 2`, which drops the eave + * line by `(wallThickness / 2) · tanθ` and raises the ridge by the same + * amount so the apex stays at `wallHeight + activeRh`. + */ + +export type RoofWallFaceId = 'front' | 'back' | 'right' | 'left' + +export type RoofSegmentWallFace = { + id: RoofWallFaceId + /** Outward normal in segment-local space. */ + normal: [number, number, number] + /** + * Yaw (radians, rotation-y) mapping opening-local +Z to the outward + * normal and opening-local +X to the face's +U direction — the same + * frame a wall-hosted door/window uses relative to its wall mesh. + */ + yaw: number + /** Face length along U. */ + length: number + /** + * Placeable region, CCW polygon in face coords. `u ∈ [0, length]`, + * `v` is height above the segment base (segment-local Y). + */ + profile: [number, number][] +} + +type SegmentWallInputs = Pick< + RoofSegmentNode, + 'roofType' | 'width' | 'depth' | 'wallHeight' | 'wallThickness' | 'pitch' +> & + Partial< + Pick< + RoofSegmentNode, + | 'gambrelLowerWidthRatio' + | 'gambrelLowerHeightRatio' + | 'mansardSteepWidthRatio' + | 'mansardSteepHeightRatio' + | 'dutchHipWidthRatio' + | 'dutchHipHeightRatio' + > + > + +type WallVolumeFrame = { + /** Outer wall plane extents (footprint + wallThickness). */ + wV: number + dV: number + /** Eave height of the outer volume. */ + eaveY: number + /** Ridge/peak height of the outer volume. */ + peakY: number + /** tan(pitch) of the primary slope. */ + tanTheta: number + hasSlope: boolean +} + +function getWallVolumeFrame(node: SegmentWallInputs): WallVolumeFrame { + const { activeRh, tanTheta } = getSegmentSlopeFrame(node) + const wallThickness = node.wallThickness ?? 0.1 + const autoDrop = (wallThickness / 2) * tanTheta + const wV = Math.max(0.01, node.width + wallThickness) + const dV = Math.max(0.01, node.depth + wallThickness) + const eaveY = Math.max(0.01, node.wallHeight - autoDrop) + let rh = activeRh + if (activeRh > 0) { + rh = activeRh + autoDrop + if (node.roofType === 'shed') rh = activeRh + 2 * autoDrop + } + return { + wV, + dV, + eaveY, + peakY: eaveY + Math.max(0.001, rh), + tanTheta, + hasSlope: activeRh > 0, + } +} + +const FACE_NORMALS: Record = { + front: [0, 0, 1], + back: [0, 0, -1], + right: [1, 0, 0], + left: [-1, 0, 0], +} + +const FACE_YAWS: Record = { + front: 0, + back: Math.PI, + right: Math.PI / 2, + left: -Math.PI / 2, +} + +function rectProfile(length: number, top: number): [number, number][] { + return [ + [0, 0], + [length, 0], + [length, top], + [0, top], + ] +} + +function buildFaceProfile( + node: SegmentWallInputs, + frame: WallVolumeFrame, + id: RoofWallFaceId, +): [number, number][] { + const { wV, dV, eaveY, peakY, tanTheta, hasSlope } = frame + const isEnd = id === 'right' || id === 'left' + const length = isEnd ? dV : wV + + if (!hasSlope) return rectProfile(length, eaveY) + + switch (node.roofType) { + case 'gable': { + if (!isEnd) return rectProfile(length, eaveY) + return [ + [0, 0], + [length, 0], + [length, eaveY], + [length / 2, peakY], + [0, eaveY], + ] + } + case 'gambrel': { + if (!isEnd) return rectProfile(length, eaveY) + // Kink ring sits at z = ±mz on the nominal footprint (see + // getModuleFaces); both end faces are symmetric about u = length/2. + const ratio = node.gambrelLowerWidthRatio ?? 0.5 + const mz = Math.min((node.depth / 2) * ratio, length / 2) + const kinkY = eaveY + (length / 2 - mz) * tanTheta + return [ + [0, 0], + [length, 0], + [length, eaveY], + [length / 2 + mz, kinkY], + [length / 2, peakY], + [length / 2 - mz, kinkY], + [0, eaveY], + ] + } + case 'shed': { + // Slope falls toward +Z: 'back' is the full-height wall, the end + // faces are right trapezoids rising toward the back edge. + if (id === 'front') return rectProfile(length, eaveY) + if (id === 'back') return rectProfile(length, peakY) + if (id === 'right') { + return [ + [0, 0], + [length, 0], + [length, peakY], + [0, eaveY], + ] + } + return [ + [0, 0], + [length, 0], + [length, eaveY], + [0, peakY], + ] + } + // hip / mansard / dutch slope on every side (dutch gablets are + // recessed from the wall plane), so only the base rect is placeable. + default: + return rectProfile(length, eaveY) + } +} + +export function getRoofSegmentWallFace( + node: SegmentWallInputs, + id: RoofWallFaceId, +): RoofSegmentWallFace { + const frame = getWallVolumeFrame(node) + const isEnd = id === 'right' || id === 'left' + return { + id, + normal: FACE_NORMALS[id], + yaw: FACE_YAWS[id], + length: isEnd ? frame.dV : frame.wV, + profile: buildFaceProfile(node, frame, id), + } +} + +export function getRoofSegmentWallFaces(node: SegmentWallInputs): RoofSegmentWallFace[] { + const frame = getWallVolumeFrame(node) + return (['front', 'back', 'right', 'left'] as const).map((id) => ({ + id, + normal: FACE_NORMALS[id], + yaw: FACE_YAWS[id], + length: id === 'right' || id === 'left' ? frame.dV : frame.wV, + profile: buildFaceProfile(node, frame, id), + })) +} + +/** + * Face coords → segment-local point on the outer wall plane. `inset` + * pushes the point inward along the face normal — openings store their + * center at the wall mid-plane (`inset = wallThickness / 2`) so the + * frame assembly centers inside the wall like on a regular wall host. + */ +export function roofWallFaceLocalToSegment( + node: SegmentWallInputs, + id: RoofWallFaceId, + u: number, + v: number, + inset = 0, +): [number, number, number] { + const { wV, dV } = getWallVolumeFrame(node) + switch (id) { + case 'front': + return [u - wV / 2, v, dV / 2 - inset] + case 'back': + return [wV / 2 - u, v, -dV / 2 + inset] + case 'right': + return [wV / 2 - inset, v, dV / 2 - u] + case 'left': + return [-wV / 2 + inset, v, u - dV / 2] + } +} + +/** + * Segment-local point → face coords. `dist` is the signed offset off the + * outer wall plane along the face normal (0 = on the plane, positive = + * outside the volume). + */ +export function segmentPointToRoofWallFace( + node: SegmentWallInputs, + id: RoofWallFaceId, + point: [number, number, number], +): { u: number; v: number; dist: number } { + const { wV, dV } = getWallVolumeFrame(node) + const [x, y, z] = point + switch (id) { + case 'front': + return { u: x + wV / 2, v: y, dist: z - dV / 2 } + case 'back': + return { u: wV / 2 - x, v: y, dist: -z - dV / 2 } + case 'right': + return { u: dV / 2 - z, v: y, dist: x - wV / 2 } + case 'left': + return { u: z + dV / 2, v: y, dist: -x - wV / 2 } + } +} + +type FaceConstraint = { + nu: number + nv: number + c: number +} + +/** + * Inward half-plane constraints of the raw profile polygon (CCW → + * interior is to the left of each edge): a point p is inside when + * `nu·p.u + nv·p.v ≥ c` for every constraint. + */ +function getProfileConstraints(face: RoofSegmentWallFace): FaceConstraint[] { + const constraints: FaceConstraint[] = [] + const pts = face.profile + for (let i = 0; i < pts.length; i++) { + const a = pts[i]! + const b = pts[(i + 1) % pts.length]! + const du = b[0] - a[0] + const dv = b[1] - a[1] + const len = Math.hypot(du, dv) + if (len < 1e-9) continue + const nu = -dv / len + const nv = du / len + constraints.push({ nu, nv, c: nu * a[0] + nv * a[1] }) + } + return constraints +} + +/** + * Half-plane constraints for the CENTER of a `width × height` rect that + * must fit inside the face profile — the raw constraints eroded by the + * rect's half-extents projected on each edge normal. + */ +function getRectCenterConstraints( + face: RoofSegmentWallFace, + width: number, + height: number, +): FaceConstraint[] { + return getProfileConstraints(face).map(({ nu, nv, c }) => ({ + nu, + nv, + c: c + (Math.abs(nu) * width) / 2 + (Math.abs(nv) * height) / 2, + })) +} + +/** Face id for an opening's stored yaw (`rotation[1]`), or null. */ +export function getRoofWallFaceIdFromYaw(yaw: number): RoofWallFaceId | null { + const tau = Math.PI * 2 + const normalized = ((yaw % tau) + tau) % tau + const eps = 1e-3 + if (normalized < eps || tau - normalized < eps) return 'front' + if (Math.abs(normalized - Math.PI) < eps) return 'back' + if (Math.abs(normalized - Math.PI / 2) < eps) return 'right' + if (Math.abs(normalized - (3 * Math.PI) / 2) < eps) return 'left' + return null +} + +/** + * Max width of a rect growing from an anchored vertical edge (`anchorU`) + * in direction `growSign` (±1 along U) while staying inside the face + * profile at the fixed vertical center `vCenter`. Resize-handle limit: + * the anchored-edge model matches the handles' apply math (opposite + * edge stays put, center re-derives). + */ +export function getMaxRoofRectWidthFromAnchor( + face: RoofSegmentWallFace, + anchorU: number, + growSign: number, + vCenter: number, + height: number, +): number { + let max = Number.POSITIVE_INFINITY + for (const { nu, nv, c } of getProfileConstraints(face)) { + // Center at anchorU + growSign·w/2, eroded by |nu|·w/2 + |nv|·h/2: + // base + k·w ≥ 0 with k ≤ 0 only when growth approaches the edge. + const k = (nu * growSign - Math.abs(nu)) / 2 + if (k >= -1e-9) continue + const base = nu * anchorU + nv * vCenter - c - (Math.abs(nv) * height) / 2 + max = Math.min(max, Math.max(0, base / -k)) + } + return max +} + +/** + * Max height of a rect growing from an anchored horizontal edge + * (`anchorV`) in direction `growSign` (+1 = bottom anchored, grows up) + * while staying inside the face profile at the fixed horizontal center + * `uCenter`. + */ +export function getMaxRoofRectHeightFromAnchor( + face: RoofSegmentWallFace, + uCenter: number, + width: number, + anchorV: number, + growSign: number, +): number { + let max = Number.POSITIVE_INFINITY + for (const { nu, nv, c } of getProfileConstraints(face)) { + const k = (nv * growSign - Math.abs(nv)) / 2 + if (k >= -1e-9) continue + const base = nu * uCenter + nv * anchorV - c - (Math.abs(nu) * width) / 2 + max = Math.min(max, Math.max(0, base / -k)) + } + return max +} + +const CLAMP_EPSILON = 1e-4 + +/** + * Clamp a rect center so the rect fits inside the face profile. + * + * - `lockV: true` (doors): `v` is fixed; only `u` slides. Returns null + * when no `u` keeps the rect inside at that height. + * - otherwise (windows): the center is projected into the eroded convex + * region (cyclic projection — profiles are convex by construction). + * + * Returns null when the rect cannot fit anywhere on the face. + */ +export function clampRectToRoofWallFace( + face: RoofSegmentWallFace, + u: number, + v: number, + width: number, + height: number, + opts?: { lockV?: boolean }, +): { u: number; v: number } | null { + const constraints = getRectCenterConstraints(face, width, height) + if (constraints.length < 3) return null + + if (opts?.lockV) { + let lo = Number.NEGATIVE_INFINITY + let hi = Number.POSITIVE_INFINITY + for (const { nu, nv, c } of constraints) { + const rhs = c - nv * v + if (Math.abs(nu) < 1e-9) { + if (rhs > CLAMP_EPSILON) return null + continue + } + if (nu > 0) lo = Math.max(lo, rhs / nu) + else hi = Math.min(hi, rhs / nu) + } + if (lo > hi + CLAMP_EPSILON) return null + return { u: Math.min(Math.max(u, lo), hi), v } + } + + let pu = u + let pv = v + for (let iter = 0; iter < 32; iter++) { + let worst: FaceConstraint | null = null + let worstViolation = CLAMP_EPSILON + for (const constraint of constraints) { + const violation = constraint.c - (constraint.nu * pu + constraint.nv * pv) + if (violation > worstViolation) { + worstViolation = violation + worst = constraint + } + } + if (!worst) return { u: pu, v: pv } + pu += worst.nu * worstViolation + pv += worst.nv * worstViolation + } + for (const { nu, nv, c } of constraints) { + if (nu * pu + nv * pv < c - 1e-3) return null + } + return { u: pu, v: pv } +} diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 50fdcbb4..307d4191 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -28,6 +28,11 @@ export const WindowNode = BaseNode.extend({ // Wall reference wallId: z.string().optional(), + // Alternative host: a roof-segment's generated wall face (base wall + // under the roof or a coplanar gable end). When set, `position` is the + // opening center in SEGMENT-LOCAL coords on the outer wall plane and + // `rotation[1]` is the face yaw — see `roof-segment-walls.ts`. + roofSegmentId: z.string().optional(), // Overall dimensions width: z.number().default(1.5), diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index 2a5d2ae1..24f0ad46 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -105,6 +105,36 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { world?.dispose() }) + test('skips meshes hidden by an invisible ancestor (stale roof segment CSG)', () => { + registerColliderDefinition('column', ColumnNode, 'structure') + + // Mirror the roof's segments-wrapper shape: the registered mesh's own + // visible flag stays true while a hidden wrapper hides it at render + // time. The collider must match the render, not the own-flag. + const column = ColumnNode.parse({ id: 'column_test' }) + const visibleColumn = ColumnNode.parse({ id: 'column_visible', position: [3, 0, 0] }) + setSceneNodes([column, visibleColumn]) + + const wrapper = new Group() + wrapper.visible = false + const hiddenMesh = new Mesh(new BoxGeometry(10, 2, 10), new MeshBasicMaterial()) + wrapper.add(hiddenMesh) + wrapper.updateMatrixWorld(true) + sceneRegistry.nodes.set(column.id, hiddenMesh) + sceneRegistry.byType[column.type]!.add(column.id) + + mountNode(visibleColumn, [1, 2, 1], [3, 1, 0]) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).not.toBeNull() + // Bounds reflect only the visible 1×1 column at x = 3; the 10×10 mesh + // under the hidden wrapper contributed no geometry. + expect(world?.bounds?.min.x).toBeCloseTo(2.5) + expect(world?.bounds?.max.x).toBeCloseTo(3.5) + world?.dispose() + }) + test('leaves elevators to their dedicated dynamic collider meshes', () => { registerColliderDefinition('elevator', ElevatorNode, 'structure') diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 0e439eaa..24fafaa6 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -50,6 +50,22 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh { return 'isMesh' in object && (object as THREE.Mesh).isMesh } +// Renderer-effective visibility: an invisible ancestor hides the whole +// subtree at render time even when the object's own flag is true. The +// collider world must match what's rendered — the roof keeps stale, +// UNCUT per-segment CSG inside its hidden `segments-wrapper` (full-edit +// exit hides the wrapper without stripping geometry), and cloning those +// meshes would block the walkthrough player at openings the visible +// merged shell has cut through. +function isEffectivelyVisible(object: THREE.Object3D) { + let current: THREE.Object3D | null = object + while (current) { + if (!current.visible) return false + current = current.parent + } + return true +} + function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) { return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible } @@ -319,9 +335,12 @@ function collectColliderGeometriesFromNode( if (visitedMeshes.has(object)) return visitedMeshes.add(object) + // Prune hidden subtrees — children of an invisible group never render, + // so they must not collide either (see isEffectivelyVisible). + if (!object.visible) return + if ( isMesh(object) && - object.visible && isColliderMaterialVisible(object.material) && !SKIPPED_MESH_NAMES.has(object.name) ) { @@ -364,6 +383,11 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider const root = sceneRegistry.nodes.get(nodeId) if (!root) continue + // Registered objects can sit inside a hidden wrapper (roof segments + // under `segments-wrapper`) — the per-node traversal starts AT the + // object, so the ancestor chain must be checked here. + if (!isEffectivelyVisible(root)) continue + if (node.type === 'door') { const doorGeometry = createDoorLeafColliderGeometry(root, node) if (doorGeometry) { diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index d1978c9a..906b1b3b 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -3,8 +3,11 @@ import type { DoorNode as DoorNodeType, HandleDescriptor, NodeDefinition, + RoofSegmentNode, WallNode, } from '@pascal-app/core' +import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' +import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { scaleHandleHeight } from './door-math' import { buildDoorFloorplan } from './floorplan' import { doorWidthAffordance } from './floorplan-affordances' @@ -42,7 +45,13 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor // 'max' = +X edge anchored (left arrow grows the -X edge outward). anchor: side === 'right' ? 'min' : 'max', min: MIN_DOOR_WIDTH, - max: (n, scene) => readWallLength(n, scene), + max: (n, scene) => { + // Roof-hosted doors clamp against the face profile (the wall-based + // limits read Infinity when wallId is unset). + const roofMax = readRoofFaceWidthMax(n, scene, sign) + if (roofMax !== null) return Math.max(MIN_DOOR_WIDTH, roofMax) + return readWallLength(n, scene) + }, currentValue: (n) => n.width, apply: (initial, newWidth) => { // Anchored edge stays fixed in wall-local coords. Door rotation is @@ -80,6 +89,8 @@ function doorHeightHandle(): HandleDescriptor { anchor: 'min', // bottom anchored at wall-local Y = position[1] - height/2 min: MIN_DOOR_HEIGHT, max: (n, scene) => { + const roofMax = readRoofFaceHeightMax(n, scene, 1) + if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax) const bottom = n.position[1] - n.height / 2 return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom) }, @@ -147,10 +158,22 @@ export const doorDefinition: NodeDefinition = { duplicable: true, deletable: true, wallOpeningPlacement: true, - // `wallId` ties the door to its host wall and is re-derived from - // the wall under the cursor when a preset is placed. Host apps - // strip this at preset-save time via `getHostRefFields(def)`. - hostRefFields: ['wallId'], + // 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 + // 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, + }, + // `wallId` / `roofSegmentId` tie the door to its host and are + // re-derived from the surface under the cursor when a preset is + // placed. Host apps strip these at preset-save time via + // `getHostRefFields(def)`. + hostRefFields: ['wallId', 'roofSegmentId'], }, parametrics: doorParametrics, diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index 0aca81ca..5ea4f71f 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -8,6 +8,10 @@ import { } from '@pascal-app/core' import { snapToHalf } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' +import { + getRoofHostedOpeningLevelId, + getRoofHostedOpeningPlanPoint, +} from '../shared/roof-opening-host' import { findClosestWallInPlan, projectWallLocalPointToPlan, @@ -35,11 +39,13 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // Snapshot of the door's "valid" state at move-start — used by // canCommit to decide whether the current snapped position is OK. const startLevelId = (() => { - // Walk up via parentId until we hit a node whose type isn't 'wall' - // — that's the level (or null). The door is wall-hosted, so the - // wall's parent is the level. Cached at start because the parent - // chain doesn't change during a move. - const wall = useScene.getState().nodes[node.parentId as AnyNodeId] + // Wall-hosted: the wall's parent is the level. Roof-hosted: walk + // segment → roof → level. Cached at start because the parent chain + // doesn't change during a move. + const nodes = useScene.getState().nodes + const roofLevelId = getRoofHostedOpeningLevelId(node, nodes) + if (roofLevelId) return roofLevelId + const wall = nodes[node.parentId as AnyNodeId] return wall ? (wall.parentId as AnyNodeId | null) : null })() const originalWall = node.parentId @@ -49,7 +55,10 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) original: originalWall?.type === 'wall' ? projectWallLocalPointToPlan(originalWall, node.position[0]) - : [node.position[0], 0], + : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [ + node.position[0], + 0, + ]), metadata: node.metadata, }) @@ -62,6 +71,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) side: DoorNode['side'] parentId: string wallId: string + roofSegmentId: undefined } | null = null const session: FloorplanMoveTargetSession = { @@ -94,6 +104,9 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) side: hit.side, parentId: hit.wall.id, wallId: hit.wall.id, + // Re-anchoring to a wall ends any roof-segment hosting; the + // overlay's snapshot restores it if the move is reverted. + roofSegmentId: undefined, } // Build the updates atomically — position + rotation + side + diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index a8647288..701a5fbb 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -1,9 +1,13 @@ import { type AnyNodeId, + clampRectToRoofWallFace, collectAlignmentAnchors, DoorNode, emitter, isCurvedWall, + type RoofEvent, + type RoofNode, + roofWallFaceLocalToSegment, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -23,8 +27,9 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' -import { BoxGeometry, EdgesGeometry, type Group } from 'three' +import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -35,6 +40,8 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) +const roofCursorPoint = new Vector3() + const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const cursorGroupRef = useRef(null!) @@ -57,6 +64,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: movingDoorNode.side, parentId: movingDoorNode.parentId, wallId: movingDoorNode.wallId, + // Doors can be hosted on a roof-segment wall face. Moving onto a + // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts + // must restore the roof host. + roofSegmentId: movingDoorNode.roofSegmentId, metadata: movingDoorNode.metadata, } @@ -207,6 +218,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: target.side, parentId: target.wallId, wallId: target.wallId, + roofSegmentId: undefined, }) markWallDirty(currentWallId) currentWallId = target.wallId @@ -284,6 +296,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: target.side, wallId: target.wallId, parentId: target.wallId, + roofSegmentId: undefined, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -294,6 +307,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) useScene.temporal.getState().resume() @@ -304,6 +318,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: target.side, parentId: target.wallId, wallId: target.wallId, + roofSegmentId: undefined, metadata: {}, }) @@ -340,6 +355,182 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, + }) + if (original.parentId) markWallDirty(original.parentId) + } + + // ── Roof-segment wall faces ───────────────────────────────────── + // Mirrors the wall flow for the segments' vertical wall faces (base + // 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) => { + 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 + const position = roofWallFaceLocalToSegment( + hit.segment, + hit.face.id, + clamped.u, + clamped.v, + (hit.segment.wallThickness ?? 0.1) / 2, + ) + const valid = !hasRoofFaceChildOverlap( + hit.segment, + hit.face, + clamped.u, + clamped.v, + movingDoorNode.width, + movingDoorNode.height, + movingDoorNode.id, + ) + return { hit, position, yaw: hit.face.yaw, 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) + roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + segObj.localToWorld(roofCursorPoint) + updateCursor( + worldToBuildingLocal(roofCursorPoint), + (target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + target.valid, + ) + } + + const onRoofHover = (event: RoofEvent) => { + const target = resolveRoofMoveTarget(event) + if (!target) return + // Wall-frame drag anchor / live transform don't apply on a roof face. + dragAnchor = null + lastTarget = null + useLiveTransforms.getState().clear(movingDoorNode.id) + if (currentWallId !== target.hit.segment.id) { + useScene.getState().updateNode(movingDoorNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + parentId: target.hit.segment.id, + wallId: undefined, + roofSegmentId: target.hit.segment.id, + }) + markWallDirty(currentWallId) + currentWallId = target.hit.segment.id + } else { + useScene.getState().updateNode(movingDoorNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + }) + } + updateRoofCursor(target) + event.stopPropagation() + } + + const onRoofClick = (event: RoofEvent) => { + const target = resolveRoofMoveTarget(event) + if (!target?.valid) return + const segmentId = target.hit.segment.id + + let placedId: string + + if (isNew) { + useScene.getState().deleteNode(movingDoorNode.id) + useScene.temporal.getState().resume() + + const cloned = structuredClone(movingDoorNode) as any + delete cloned.id + cloned.metadata = stripPlacementMetadataFlags(cloned.metadata) + const node = DoorNode.parse({ + ...cloned, + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + wallId: undefined, + roofSegmentId: segmentId, + parentId: segmentId, + }) + useScene.getState().createNode(node, segmentId as AnyNodeId) + placedId = node.id + } else { + useScene.getState().updateNode(movingDoorNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + roofSegmentId: original.roofSegmentId, + metadata: original.metadata, + }) + useScene.temporal.getState().resume() + + useScene.getState().updateNode(movingDoorNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + parentId: segmentId, + wallId: undefined, + roofSegmentId: segmentId, + metadata: {}, + }) + + if (original.parentId && original.parentId !== segmentId) { + markWallDirty(original.parentId) + } + placedId = movingDoorNode.id + } + + markWallDirty(segmentId) + useLiveTransforms.getState().clear(movingDoorNode.id) + useScene.temporal.getState().pause() + + triggerSFX('sfx:structure-build') + hideCursor() + useViewer.getState().setSelection({ selectedIds: [placedId] }) + exitMoveMode() + event.stopPropagation() + } + + const onRoofLeave = () => { + hideCursor() + useLiveTransforms.getState().clear(movingDoorNode.id) + dragAnchor = null + lastTarget = null + if (isNew) return + if (currentWallId && currentWallId !== original.parentId) { + markWallDirty(currentWallId) + } + currentWallId = original.parentId + useScene.getState().updateNode(movingDoorNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + roofSegmentId: original.roofSegmentId, }) if (original.parentId) markWallDirty(original.parentId) } @@ -356,6 +547,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -369,6 +561,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) + emitter.on('roof:enter', onRoofHover) + emitter.on('roof:move', onRoofHover) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) emitter.on('tool:cancel', onCancel) return () => { @@ -387,6 +583,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -399,6 +596,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) + emitter.off('roof:enter', onRoofHover) + emitter.off('roof:move', onRoofHover) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) } }, [movingDoorNode, exitMoveMode]) diff --git a/packages/nodes/src/door/renderer.tsx b/packages/nodes/src/door/renderer.tsx index 17cb3c3e..2925e78b 100644 --- a/packages/nodes/src/door/renderer.tsx +++ b/packages/nodes/src/door/renderer.tsx @@ -1,6 +1,12 @@ 'use client' -import { type DoorNode, useRegistry, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + type DoorNode, + type RoofSegmentNode, + useRegistry, + useScene, +} from '@pascal-app/core' import { useNodeEvents } from '@pascal-app/viewer' import { useLayoutEffect, useRef } from 'react' import { type Mesh, MeshBasicMaterial } from 'three' @@ -17,7 +23,17 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { const handlers = useNodeEvents(node, 'door') const isTransient = !!(node.metadata as Record | null)?.isTransient - return ( + // Roof-hosted doors mount under the roof's `roof-elements` group (roof + // frame), so the host segment's transform is applied here — wall-hosted + // doors get it for free from the wall mesh they're nested in. + const segment = useScene((state) => + node.roofSegmentId + ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined, + ) + if (node.roofSegmentId && segment?.type !== 'roof-segment') return null + + const mesh = ( { ) + + if (!segment) return mesh + return ( + + {mesh} + + ) } export default DoorRenderer diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index eaec7683..cf00b221 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -1,9 +1,13 @@ import { type AnyNodeId, + clampRectToRoofWallFace, collectAlignmentAnchors, DoorNode, emitter, isCurvedWall, + type RoofEvent, + type RoofNode, + roofWallFaceLocalToSegment, sceneRegistry, spatialGridManager, useScene, @@ -20,8 +24,9 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' -import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' +import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -32,9 +37,13 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) +const roofCursorPoint = new Vector3() + /** - * Door tool — places DoorNodes on walls only. - * Doors always sit at floor level (clampedY = height/2). + * Door tool — places DoorNodes on walls and on roof-segment wall faces + * (the generated base walls under a roof, including coplanar gable ends). + * Doors always sit at floor level (clampedY = height/2 — segment base for + * roof-hosted doors). */ const DoorTool: React.FC = () => { const draftRef = useRef(null) @@ -215,6 +224,8 @@ const DoorTool: React.FC = () => { side, parentId: event.node.id, wallId: event.node.id, + // The draft may arrive from a roof-segment face hover. + roofSegmentId: undefined, }) } } @@ -335,6 +346,168 @@ const DoorTool: React.FC = () => { hideCursor() } + // ── Roof-segment wall faces ───────────────────────────────────── + // 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, + }) + if (!clamped) return null + const position = roofWallFaceLocalToSegment( + hit.segment, + hit.face.id, + clamped.u, + clamped.v, + (hit.segment.wallThickness ?? 0.1) / 2, + ) + const valid = !hasRoofFaceChildOverlap( + hit.segment, + hit.face, + clamped.u, + clamped.v, + width, + height, + draftRef.current?.id, + ) + return { hit, position, yaw: hit.face.yaw, 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) + roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + segObj.localToWorld(roofCursorPoint) + updateCursor( + worldToBuildingLocal(roofCursorPoint), + (roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + target.valid, + ) + } + + const onRoofHover = (event: RoofEvent) => { + const target = resolveRoofTarget(event) + if (!target) { + // On the roof but not over a placeable wall face (slope, soffit, + // or a face the door cannot fit on). + if (draftRef.current?.roofSegmentId) { + destroyDraft() + hideCursor() + } + return + } + const { hit, position, yaw } = target + + if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft() + if (draftRef.current) { + useScene.getState().updateNode(draftRef.current.id, { + position, + rotation: [0, yaw, 0], + }) + } else { + const node = DoorNode.parse({ + position, + rotation: [0, yaw, 0], + side: 'front', + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + draftRef.current = node + } + updateRoofCursor(target, event.node as RoofNode) + event.stopPropagation() + } + + const onRoofClick = (event: RoofEvent) => { + if (!draftRef.current?.roofSegmentId) return + const target = resolveRoofTarget(event) + if (!target?.valid) return + const { hit, position, yaw } = target + + const draft = draftRef.current + draftRef.current = null + + useScene.getState().deleteNode(draft.id) + useScene.temporal.getState().resume() + + const state = useScene.getState() + const doorCount = Object.values(state.nodes).filter( + (n) => n.type === 'door' && (n as DoorNode).roofSegmentId !== undefined, + ).length + + const node = DoorNode.parse({ + name: `Door ${doorCount + 1}`, + position, + rotation: [0, yaw, 0], + side: 'front', + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + width: draft.width, + height: draft.height, + doorCategory: draft.doorCategory, + doorType: draft.doorType, + leafCount: draft.leafCount, + operationState: draft.operationState, + slideDirection: draft.slideDirection, + trackStyle: draft.trackStyle, + garagePanelCount: draft.garagePanelCount, + frameThickness: draft.frameThickness, + frameDepth: draft.frameDepth, + threshold: draft.threshold, + thresholdHeight: draft.thresholdHeight, + hingesSide: draft.hingesSide, + swingDirection: draft.swingDirection, + segments: draft.segments, + handle: draft.handle, + handleHeight: draft.handleHeight, + handleSide: draft.handleSide, + doorCloser: draft.doorCloser, + panicBar: draft.panicBar, + panicBarHeight: draft.panicBarHeight, + }) + + useScene.getState().createNode(node, hit.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) + useViewer.getState().setSelection({ selectedIds: [node.id] }) + useScene.temporal.getState().pause() + triggerSFX('sfx:structure-build') + event.stopPropagation() + } + + const onRoofLeave = () => { + if (!draftRef.current?.roofSegmentId) return + destroyDraft() + hideCursor() + } + const onCancel = () => { destroyDraft() hideCursor() @@ -344,6 +517,10 @@ const DoorTool: React.FC = () => { emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) + emitter.on('roof:enter', onRoofHover) + emitter.on('roof:move', onRoofHover) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) emitter.on('tool:cancel', onCancel) return () => { @@ -355,6 +532,10 @@ const DoorTool: React.FC = () => { emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) + emitter.off('roof:enter', onRoofHover) + emitter.off('roof:move', onRoofHover) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) } }, []) diff --git a/packages/nodes/src/shared/roof-opening-host.ts b/packages/nodes/src/shared/roof-opening-host.ts new file mode 100644 index 00000000..8b604e84 --- /dev/null +++ b/packages/nodes/src/shared/roof-opening-host.ts @@ -0,0 +1,108 @@ +import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '@pascal-app/core' +import { + getMaxRoofRectHeightFromAnchor, + getMaxRoofRectWidthFromAnchor, + getRoofSegmentWallFace, + getRoofWallFaceIdFromYaw, + segmentPointToRoofWallFace, +} from '@pascal-app/core' + +/** + * Host-side helpers for openings (door / window) hosted on a roof-segment + * wall face: resize-handle limits derived from the face profile, and the + * plan-space anchors the 2D floor-plan move path needs. + */ + +type RoofHostedOpening = { + roofSegmentId?: string + parentId: string | null + position: [number, number, number] + rotation: [number, number, number] + width: number + height: number +} + +type SceneReader = { get: (id: AnyNodeId) => unknown } + +function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) { + if (!node.roofSegmentId) return null + const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined + if (!segment || segment.type !== 'roof-segment') return null + const faceId = getRoofWallFaceIdFromYaw(node.rotation[1]) + if (!faceId) return null + const face = getRoofSegmentWallFace(segment, faceId) + const { u, v } = segmentPointToRoofWallFace(segment, faceId, node.position) + return { segment, face, u, v } +} + +/** + * Resize-handle width limit for a roof-hosted opening: the opposite edge + * is anchored, `growSign` (+1 = door-local +X arrow) is the direction + * the dragged edge moves. Null when the node is not roof-hosted. + */ +export function readRoofFaceWidthMax( + node: RoofHostedOpening, + scene: SceneReader, + growSign: number, +): number | null { + const host = resolveHostFace(node, scene) + if (!host) return null + const anchorU = host.u - (growSign * node.width) / 2 + return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, host.v, node.height) +} + +/** + * Resize-handle height limit for a roof-hosted opening. `growSign` +1 = + * bottom edge anchored, top grows up; -1 = top anchored, bottom grows + * down. Null when the node is not roof-hosted. + */ +export function readRoofFaceHeightMax( + node: RoofHostedOpening, + scene: SceneReader, + growSign: number, +): number | null { + const host = resolveHostFace(node, scene) + if (!host) return null + const anchorV = host.v - (growSign * node.height) / 2 + return getMaxRoofRectHeightFromAnchor(host.face, host.u, node.width, anchorV, growSign) +} + +/** + * Level hosting a roof-hosted opening's roof (opening → segment → roof → + * level). Null when the parent chain isn't roof-shaped. + */ +export function getRoofHostedOpeningLevelId( + node: RoofHostedOpening, + nodes: Record, +): AnyNodeId | null { + const segment = node.parentId ? nodes[node.parentId] : undefined + if (segment?.type !== 'roof-segment') return null + const roof = segment.parentId ? nodes[segment.parentId] : undefined + if (roof?.type !== 'roof') return null + return (roof.parentId as AnyNodeId | null) ?? null +} + +/** + * Level-plan [x, z] of a roof-hosted opening — its segment-local center + * composed through the segment's and roof's yaw + position. + */ +export function getRoofHostedOpeningPlanPoint( + node: RoofHostedOpening, + nodes: Record, +): [number, number] | null { + const segment = node.parentId ? (nodes[node.parentId] as RoofSegmentNode | undefined) : undefined + if (segment?.type !== 'roof-segment') return null + const roof = segment.parentId ? (nodes[segment.parentId] as RoofNode | undefined) : undefined + if (roof?.type !== 'roof') return null + + const rotate = (x: number, z: number, yaw: number): [number, number] => [ + x * Math.cos(yaw) + z * Math.sin(yaw), + -x * Math.sin(yaw) + z * Math.cos(yaw), + ] + + const [sx, sz] = rotate(node.position[0], node.position[2], segment.rotation ?? 0) + const segX = sx + segment.position[0] + const segZ = sz + segment.position[2] + const [rx, rz] = rotate(segX, segZ, roof.rotation ?? 0) + return [rx + roof.position[0], rz + roof.position[2]] +} diff --git a/packages/nodes/src/shared/roof-wall-hit.ts b/packages/nodes/src/shared/roof-wall-hit.ts new file mode 100644 index 00000000..2a186e56 --- /dev/null +++ b/packages/nodes/src/shared/roof-wall-hit.ts @@ -0,0 +1,152 @@ +import { + type AnyNodeId, + getRoofSegmentWallFaces, + type RoofNode, + type RoofSegmentNode, + type RoofSegmentWallFace, + sceneRegistry, + segmentPointToRoofWallFace, + useScene, +} from '@pascal-app/core' +import * as THREE from 'three' + +const worldPoint = new THREE.Vector3() +const worldNormal = new THREE.Vector3() +const localPoint = new THREE.Vector3() +const localNormal = new THREE.Vector3() +const inverseMatrix = new THREE.Matrix4() + +export type RoofWallHit = { + segment: RoofSegmentNode + face: RoofSegmentWallFace + /** Face coords of the hit (u along the face, v above the segment base). */ + u: number + v: number +} + +/** Pointer hits more than this far off the wall plane are not wall hits. */ +const PLANE_TOLERANCE = 0.06 +/** Reject faces whose normal disagrees with the hit normal (slope / soffit). */ +const NORMAL_ALIGNMENT = 0.7 +/** A wall face is vertical; slope faces on low pitches have |ny| ≫ 0. */ +const MAX_NORMAL_Y = 0.4 + +/** + * Resolve a pointer hit on a roof to one of its segments' vertical wall + * faces (base walls under the roof + the coplanar gable/shed/gambrel end + * faces). Counterpart of `resolveRoofSegmentHit`, which resolves to the + * sloped top surface instead. + * + * `normal` must be the raw `NodeEvent.normal` (hit-object-local) together + * with the `object` it came from — roof events can originate from the + * merged-roof mesh (roof-local frame) or a painted segment mesh + * (segment-local frame), so the normal is normalised through world space + * here instead of trusting the event frame. + */ +export function resolveRoofWallHit( + roof: RoofNode, + position: [number, number, number], + normal: [number, number, number] | undefined, + object: THREE.Object3D | undefined, +): RoofWallHit | null { + if (!normal || !object) return null + + worldPoint.set(position[0], position[1], position[2]) + worldNormal.set(normal[0], normal[1], normal[2]) + object.updateWorldMatrix(true, false) + worldNormal.transformDirection(object.matrixWorld) + + const state = useScene.getState() + let best: { hit: RoofWallHit; score: number } | null = null + + for (const childId of roof.children ?? []) { + const segment = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined + if (segment?.type !== 'roof-segment') continue + const segObj = sceneRegistry.nodes.get(segment.id) + if (!segObj) continue + segObj.updateWorldMatrix(true, false) + + localPoint.copy(worldPoint) + segObj.worldToLocal(localPoint) + inverseMatrix.copy(segObj.matrixWorld).invert() + localNormal.copy(worldNormal).transformDirection(inverseMatrix) + + if (Math.abs(localNormal.y) > MAX_NORMAL_Y) continue + + for (const face of getRoofSegmentWallFaces(segment)) { + const alignment = + localNormal.x * face.normal[0] + + localNormal.y * face.normal[1] + + localNormal.z * face.normal[2] + if (alignment < NORMAL_ALIGNMENT) continue + + const { u, v, dist } = segmentPointToRoofWallFace(segment, face.id, [ + localPoint.x, + localPoint.y, + localPoint.z, + ]) + if (Math.abs(dist) > PLANE_TOLERANCE) continue + if (u < -PLANE_TOLERANCE || u > face.length + PLANE_TOLERANCE) continue + if (v < -PLANE_TOLERANCE) continue + + const score = Math.abs(dist) + if (!best || score < best.score) { + best = { hit: { segment, face, u, v }, score } + } + } + } + + return best?.hit ?? null +} + +/** + * Overlap guard for openings sharing a roof-segment wall face — the + * roof-host analogue of `hasWallChildOverlap`. Only door / window + * siblings on the same face are compared (other accessories live on the + * sloped surfaces). + */ +export function hasRoofFaceChildOverlap( + segment: RoofSegmentNode, + face: RoofSegmentWallFace, + u: number, + v: number, + width: number, + height: number, + ignoreId?: string, +): boolean { + const nodes = useScene.getState().nodes + const newLeft = u - width / 2 + const newRight = u + width / 2 + const newBottom = v - height / 2 + const newTop = v + height / 2 + // Sibling openings store their center at the wall mid-plane (inset by + // wallThickness / 2 from the outer plane this face measures from). + const sameFaceTolerance = (segment.wallThickness ?? 0.1) / 2 + PLANE_TOLERANCE + + for (const childId of segment.children ?? []) { + if (childId === ignoreId) continue + const child = nodes[childId as AnyNodeId] + if (!child || (child.type !== 'door' && child.type !== 'window')) continue + const opening = child as { + position: [number, number, number] + rotation: [number, number, number] + width: number + height: number + } + const { + u: childU, + v: childV, + dist, + } = segmentPointToRoofWallFace(segment, face.id, [ + opening.position[0], + opening.position[1], + opening.position[2], + ]) + // Same face = the opening's mid-plane center sits near this face. + if (Math.abs(dist) > sameFaceTolerance) continue + const xOverlap = newLeft < childU + opening.width / 2 && newRight > childU - opening.width / 2 + const yOverlap = newBottom < childV + opening.height / 2 && newTop > childV - opening.height / 2 + if (xOverlap && yOverlap) return true + } + return false +} diff --git a/packages/nodes/src/shared/roof-wall-opening-cut.ts b/packages/nodes/src/shared/roof-wall-opening-cut.ts new file mode 100644 index 00000000..f9e3d525 --- /dev/null +++ b/packages/nodes/src/shared/roof-wall-opening-cut.ts @@ -0,0 +1,42 @@ +import type { RoofSegmentNode } from '@pascal-app/core' +import * as THREE from 'three' + +type RoofWallOpening = { + roofSegmentId?: string + position: [number, number, number] + rotation: [number, number, number] + width: number + height: number +} + +/** + * CSG cut for a door / window hosted on a roof-segment wall face + * (`capabilities.roofAccessory.buildCut`). A box through the wall plane, + * oriented by the opening's face yaw, in segment-local coords — the + * roof-merge loop subtracts it from the segment's wall brush. + * + * Returns null for wall-hosted openings (no `roofSegmentId`): their cut + * is handled by the wall system's own cutout pipeline. + */ +export function buildRoofWallOpeningCut( + node: RoofWallOpening, + hostSegment: RoofSegmentNode, +): THREE.BufferGeometry | null { + if (!node.roofSegmentId) return null + + const wallThickness = hostSegment.wallThickness ?? 0.1 + // Through the wall both ways, but well short of the rake/eave overhang + // so the cut never nicks the soffit or fascia bands. + const depth = wallThickness * 2 + 0.04 + + // A door's cut bottom is coplanar with the wall brush base — extend it + // slightly downward so three-bvh-csg never has to clip coplanar faces. + const bottom = node.position[1] - node.height / 2 + const bottomPad = bottom < 0.005 ? 0.02 : 0 + + const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth) + geo.translate(0, -bottomPad / 2, 0) + geo.rotateY(node.rotation[1] ?? 0) + geo.translate(node.position[0], node.position[1], node.position[2]) + return geo +} diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index d083a4bd..8d5bbb79 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -2,9 +2,12 @@ import type { AnyNodeId, HandleDescriptor, NodeDefinition, + RoofSegmentNode, WallNode, WindowNode as WindowNodeType, } from '@pascal-app/core' +import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' +import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { buildWindowFloorplan } from './floorplan' import { windowWidthAffordance } from './floorplan-affordances' import { windowFloorplanMoveTarget } from './floorplan-move' @@ -36,7 +39,13 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor readWallLength(n, scene), + max: (n, scene) => { + // Roof-hosted windows clamp against the face profile (the + // wall-based limits read Infinity when wallId is unset). + const roofMax = readRoofFaceWidthMax(n, scene, sign) + if (roofMax !== null) return Math.max(MIN_WINDOW_WIDTH, roofMax) + return readWallLength(n, scene) + }, currentValue: (n) => n.width, apply: (initial, newWidth) => { const rotY = initial.rotation[1] @@ -73,6 +82,8 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor { + const roofMax = readRoofFaceHeightMax(n, scene, sign) + if (roofMax !== null) return Math.max(MIN_WINDOW_HEIGHT, roofMax) // Maximum: distance from the anchored edge to the wall's allowed Y // bounds. Top arrow caps at wall.height - bottom; bottom arrow caps // at top (positive Y room above the floor). @@ -139,9 +150,18 @@ export const windowDefinition: NodeDefinition = { duplicable: true, deletable: true, wallOpeningPlacement: true, - // `wallId` is re-derived from the wall under the cursor at preset - // placement time — see the door capability for the same pattern. - hostRefFields: ['wallId'], + // 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. + roofAccessory: { + buildCut: (node, hostSegment) => + buildRoofWallOpeningCut(node as WindowNodeType, hostSegment as RoofSegmentNode), + cutScope: 'wall', + cascadesViaHostSegment: true, + }, + // `wallId` / `roofSegmentId` are re-derived from the surface under + // the cursor at preset placement time — see door for the pattern. + hostRefFields: ['wallId', 'roofSegmentId'], }, parametrics: windowParametrics, diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index 9428a47e..bcd7c82e 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -8,6 +8,10 @@ import { } from '@pascal-app/core' import { snapToHalf } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' +import { + getRoofHostedOpeningLevelId, + getRoofHostedOpeningPlanPoint, +} from '../shared/roof-opening-host' import { findClosestWallInPlan, projectWallLocalPointToPlan, @@ -29,7 +33,12 @@ import { clampToWall, hasWallChildOverlap } from './window-math' export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { const startLevelId = (() => { - const wall = useScene.getState().nodes[node.parentId as AnyNodeId] + // Wall-hosted: the wall's parent is the level. Roof-hosted: walk + // segment → roof → level. + const nodes = useScene.getState().nodes + const roofLevelId = getRoofHostedOpeningLevelId(node, nodes) + if (roofLevelId) return roofLevelId + const wall = nodes[node.parentId as AnyNodeId] return wall ? (wall.parentId as AnyNodeId | null) : null })() const originalWall = node.parentId @@ -39,7 +48,10 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod original: originalWall?.type === 'wall' ? projectWallLocalPointToPlan(originalWall, node.position[0]) - : [node.position[0], 0], + : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [ + node.position[0], + 0, + ]), metadata: node.metadata, }) @@ -56,6 +68,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod side: WindowNode['side'] parentId: string wallId: string + roofSegmentId: undefined } | null = null const session: FloorplanMoveTargetSession = { @@ -93,6 +106,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod side: hit.side, parentId: hit.wall.id, wallId: hit.wall.id, + // Re-anchoring to a wall ends any roof-segment hosting; the + // overlay's snapshot restores it if the move is reverted. + roofSegmentId: undefined, } useScene.getState().updateNodes([ diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 59b39dee..15936a6d 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,8 +1,12 @@ import { type AnyNodeId, + clampRectToRoofWallFace, collectAlignmentAnchors, emitter, isCurvedWall, + type RoofEvent, + type RoofNode, + roofWallFaceLocalToSegment, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -23,8 +27,9 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' -import { BoxGeometry, EdgesGeometry, type Group } from 'three' +import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -35,6 +40,8 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) +const roofCursorPoint = new Vector3() + /** * Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool. * @@ -70,6 +77,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: movingWindowNode.side, parentId: movingWindowNode.parentId, wallId: movingWindowNode.wallId, + // Windows can be hosted on a roof-segment wall face. Moving onto a + // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts + // must restore the roof host. + roofSegmentId: movingWindowNode.roofSegmentId, metadata: movingWindowNode.metadata, } @@ -230,6 +241,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: target.side, parentId: target.wallId, wallId: target.wallId, + roofSegmentId: undefined, }) markWallDirty(currentWallId) currentWallId = target.wallId @@ -315,6 +327,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: target.side, wallId: target.wallId, parentId: target.wallId, + roofSegmentId: undefined, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -327,6 +340,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) useScene.temporal.getState().resume() @@ -337,6 +351,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: target.side, parentId: target.wallId, wallId: target.wallId, + roofSegmentId: undefined, metadata: {}, }) @@ -374,6 +389,188 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, + }) + if (original.parentId) markWallDirty(original.parentId) + } + + // ── Roof-segment wall faces ───────────────────────────────────── + // Mirrors the wall flow for the segments' vertical wall faces (base + // walls under the roof + coplanar gable ends — a window can sit in + // 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) => { + 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 + const position = roofWallFaceLocalToSegment( + hit.segment, + hit.face.id, + clamped.u, + clamped.v, + (hit.segment.wallThickness ?? 0.1) / 2, + ) + const valid = !hasRoofFaceChildOverlap( + hit.segment, + hit.face, + clamped.u, + clamped.v, + movingWindowNode.width, + movingWindowNode.height, + movingWindowNode.id, + ) + return { hit, position, yaw: hit.face.yaw, 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) + roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + segObj.localToWorld(roofCursorPoint) + updateCursor( + worldToBuildingLocal(roofCursorPoint), + (target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + target.valid, + ) + } + + const onRoofHover = (event: RoofEvent) => { + const target = resolveRoofMoveTarget(event) + if (!target) return + // Wall-frame drag anchor / live transform don't apply on a roof face. + dragAnchor = null + lastTarget = null + useLiveTransforms.getState().clear(movingWindowNode.id) + if (currentWallId !== target.hit.segment.id) { + useScene.getState().updateNode(movingWindowNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + parentId: target.hit.segment.id, + wallId: undefined, + roofSegmentId: target.hit.segment.id, + }) + markWallDirty(currentWallId) + currentWallId = target.hit.segment.id + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + }) + } + updateRoofCursor(target) + event.stopPropagation() + } + + const onRoofClick = (event: RoofEvent) => { + const target = resolveRoofMoveTarget(event) + if (!target?.valid) return + const segmentId = target.hit.segment.id + + let placedId: string + + if (isNew) { + useScene.getState().deleteNode(movingWindowNode.id) + useScene.temporal.getState().resume() + + const cloned = structuredClone(movingWindowNode) as any + delete cloned.id + if (cloned.metadata && typeof cloned.metadata === 'object') { + delete cloned.metadata.isNew + delete cloned.metadata.isTransient + } + + const node = WindowNode.parse({ + ...cloned, + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + wallId: undefined, + roofSegmentId: segmentId, + parentId: segmentId, + }) + useScene.getState().createNode(node, segmentId as AnyNodeId) + placedId = node.id + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + roofSegmentId: original.roofSegmentId, + metadata: original.metadata, + }) + useScene.temporal.getState().resume() + + useScene.getState().updateNode(movingWindowNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + parentId: segmentId, + wallId: undefined, + roofSegmentId: segmentId, + metadata: {}, + }) + + if (original.parentId && original.parentId !== segmentId) { + markWallDirty(original.parentId) + } + placedId = movingWindowNode.id + } + + markWallDirty(segmentId) + useLiveTransforms.getState().clear(movingWindowNode.id) + useScene.temporal.getState().pause() + + triggerSFX('sfx:structure-build') + hideCursor() + useViewer.getState().setSelection({ selectedIds: [placedId] }) + exitMoveMode() + event.stopPropagation() + } + + const onRoofLeave = () => { + hideCursor() + useLiveTransforms.getState().clear(movingWindowNode.id) + dragAnchor = null + lastTarget = null + if (isNew) return + if (currentWallId && currentWallId !== original.parentId) { + markWallDirty(currentWallId) + } + currentWallId = original.parentId + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + roofSegmentId: original.roofSegmentId, }) if (original.parentId) markWallDirty(original.parentId) } @@ -390,6 +587,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -403,6 +601,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) + emitter.on('roof:enter', onRoofHover) + emitter.on('roof:move', onRoofHover) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) emitter.on('tool:cancel', onCancel) return () => { @@ -422,6 +624,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -434,6 +637,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) + emitter.off('roof:enter', onRoofHover) + emitter.off('roof:move', onRoofHover) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) } }, [movingWindowNode, exitMoveMode]) diff --git a/packages/nodes/src/window/renderer.tsx b/packages/nodes/src/window/renderer.tsx index 9f72748d..412ba635 100644 --- a/packages/nodes/src/window/renderer.tsx +++ b/packages/nodes/src/window/renderer.tsx @@ -1,6 +1,12 @@ 'use client' -import { useRegistry, useScene, type WindowNode } from '@pascal-app/core' +import { + type AnyNodeId, + type RoofSegmentNode, + useRegistry, + useScene, + type WindowNode, +} from '@pascal-app/core' import { createMaterial, DEFAULT_WINDOW_MATERIAL, @@ -33,7 +39,17 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { node.material?.texture, ]) - return ( + // Roof-hosted windows mount under the roof's `roof-elements` group (roof + // frame), so the host segment's transform is applied here — wall-hosted + // windows get it for free from the wall mesh they're nested in. + const segment = useScene((state) => + node.roofSegmentId + ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined, + ) + if (node.roofSegmentId && segment?.type !== 'roof-segment') return null + + const mesh = ( { ) + + if (!segment) return mesh + return ( + + {mesh} + + ) } export default WindowRenderer diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 7abcfbe5..44da2d34 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -1,8 +1,12 @@ import { type AnyNodeId, + clampRectToRoofWallFace, collectAlignmentAnchors, emitter, isCurvedWall, + type RoofEvent, + type RoofNode, + roofWallFaceLocalToSegment, sceneRegistry, spatialGridManager, useScene, @@ -21,8 +25,9 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' -import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' +import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -34,8 +39,12 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) +const roofCursorPoint = new Vector3() + /** - * Window tool — places WindowNodes on walls only. + * Window tool — places WindowNodes on walls and on roof-segment wall + * faces (the generated base walls under a roof, including coplanar gable + * ends — a window can sit in the gable pediment). * Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions. */ const WindowTool: React.FC = () => { @@ -223,6 +232,8 @@ const WindowTool: React.FC = () => { side, parentId: event.node.id, wallId: event.node.id, + // The draft may arrive from a roof-segment face hover. + roofSegmentId: undefined, }) } } @@ -343,6 +354,164 @@ const WindowTool: React.FC = () => { hideCursor() } + // ── Roof-segment wall faces ───────────────────────────────────── + // The merged roof mesh emits `roof:*`; hits are resolved against the + // segments' vertical wall faces (base walls + coplanar gable ends), + // 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) => { + 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 + const position = roofWallFaceLocalToSegment( + hit.segment, + hit.face.id, + clamped.u, + clamped.v, + (hit.segment.wallThickness ?? 0.1) / 2, + ) + const valid = !hasRoofFaceChildOverlap( + hit.segment, + hit.face, + clamped.u, + clamped.v, + width, + height, + draftRef.current?.id, + ) + return { hit, position, yaw: hit.face.yaw, 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) + roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + segObj.localToWorld(roofCursorPoint) + updateCursor( + worldToBuildingLocal(roofCursorPoint), + (roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + target.valid, + ) + } + + const onRoofHover = (event: RoofEvent) => { + const target = resolveRoofTarget(event) + if (!target) { + // On the roof but not over a placeable wall face (slope, soffit, + // or a face the window cannot fit on). + if (draftRef.current?.roofSegmentId) { + destroyDraft() + hideCursor() + } + return + } + const { hit, position, yaw } = target + + if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft() + if (draftRef.current) { + useScene.getState().updateNode(draftRef.current.id, { + position, + rotation: [0, yaw, 0], + }) + } else { + const node = WindowNode.parse({ + position, + rotation: [0, yaw, 0], + side: 'front', + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + draftRef.current = node + } + updateRoofCursor(target, event.node as RoofNode) + event.stopPropagation() + } + + const onRoofClick = (event: RoofEvent) => { + if (!draftRef.current?.roofSegmentId) return + const target = resolveRoofTarget(event) + if (!target?.valid) return + const { hit, position, yaw } = target + + const draft = draftRef.current + draftRef.current = null + + useScene.getState().deleteNode(draft.id) + useScene.temporal.getState().resume() + + const state = useScene.getState() + const windowCount = Object.values(state.nodes).filter( + (n) => n.type === 'window' && (n as WindowNode).roofSegmentId !== undefined, + ).length + + const node = WindowNode.parse({ + name: `Window ${windowCount + 1}`, + position, + rotation: [0, yaw, 0], + side: 'front', + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + width: draft.width, + height: draft.height, + windowType: draft.windowType, + operationState: draft.operationState, + awningDirection: draft.awningDirection, + casementStyle: draft.casementStyle, + hingesSide: draft.hingesSide, + frameThickness: draft.frameThickness, + frameDepth: draft.frameDepth, + columnRatios: draft.columnRatios, + rowRatios: draft.rowRatios, + columnDividerThickness: draft.columnDividerThickness, + rowDividerThickness: draft.rowDividerThickness, + sill: draft.sill, + sillDepth: draft.sillDepth, + sillThickness: draft.sillThickness, + }) + + useScene.getState().createNode(node, hit.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) + useViewer.getState().setSelection({ selectedIds: [node.id] }) + useScene.temporal.getState().pause() + triggerSFX('sfx:structure-build') + event.stopPropagation() + } + + const onRoofLeave = () => { + if (!draftRef.current?.roofSegmentId) return + destroyDraft() + hideCursor() + } + const onCancel = () => { destroyDraft() hideCursor() @@ -352,6 +521,10 @@ const WindowTool: React.FC = () => { emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) + emitter.on('roof:enter', onRoofHover) + emitter.on('roof:move', onRoofHover) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) emitter.on('tool:cancel', onCancel) return () => { @@ -363,6 +536,10 @@ const WindowTool: React.FC = () => { emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) + emitter.off('roof:enter', onRoofHover) + emitter.off('roof:move', onRoofHover) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) } }, []) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 79dca73e..57e74b18 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -110,7 +110,10 @@ 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) - if (def?.capabilities?.roofAccessory) { + // Kinds with `cascadesViaHostSegment` (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) { const segId = (node as { roofSegmentId?: string }).roofSegmentId const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined if (seg?.parentId) { @@ -131,10 +134,20 @@ export const RoofSystem = () => { // Only compute expensive individual CSG when the segment is actually rendered // (its parent group is visible = the roof is selected for editing) const isVisible = mesh.parent?.visible !== false - if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) { + // Accessory-reveal mode (RoofEditSystem): the wrapper is shown so + // portaled handles render, but the merged shell stays visible and + // the segment meshes are stripped to empty placeholders. Rebuilding + // per-segment CSG here would draw UNCUT geometry on top of the + // merged shell — hiding a freshly cut opening (door / window / + // skylight) until the next deselect. Full edit mode hides the + // merged mesh, so gate the rebuild on its visibility. + const revealOnly = + mesh.parent?.name === 'segments-wrapper' && + mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true + if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) { updateRoofSegmentGeometry(effectiveSegment, mesh) segmentsProcessed++ - } else if (isVisible) { + } else if (isVisible && !revealOnly) { return // Over budget — keep dirty, process next frame } else { // Just sync transform, skip CSG — the merged roof handles visuals. @@ -313,16 +326,19 @@ function updateMergedRoofGeometry( const cut = new Brush(welded, dummyMats[0]) cut.updateMatrixWorld() + const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all' try { - const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush - workingShin.geometry.dispose() - prepareBrushForCSG(nextShin) - workingShin = nextShin + if (cutScope !== 'wall') { + const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush + workingShin.geometry.dispose() + prepareBrushForCSG(nextShin) + workingShin = nextShin - const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush - workingDeck.geometry.dispose() - prepareBrushForCSG(nextDeck) - workingDeck = nextDeck + const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush + workingDeck.geometry.dispose() + prepareBrushForCSG(nextDeck) + workingDeck = nextDeck + } const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush workingWall.geometry.dispose() From 3a1ba562460cbf9042dca973cee5a776bf3d3f4f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 10 Jun 2026 11:28:12 -0400 Subject: [PATCH 10/15] Polish editor interaction behavior --- apps/editor/components/viewer-toolbar.tsx | 30 +++++- .../renderers/floorplan-registry-layer.tsx | 3 + .../editor/first-person-controls.tsx | 94 ++++++++++++++++++- .../editor/src/components/editor/index.tsx | 1 + .../components/editor/node-arrow-handles.tsx | 2 + .../ui/action-menu/control-modes.tsx | 77 ++------------- .../ui/action-menu/view-toggles.tsx | 10 +- 7 files changed, 142 insertions(+), 75 deletions(-) diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 548c11ef..49638840 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -40,6 +40,7 @@ import { } from 'lucide-react' import Image from 'next/image' import { type ReactNode, useCallback } from 'react' +import { flushSync } from 'react-dom' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip' @@ -49,6 +50,24 @@ const TOOLBAR_CONTAINER = const TOOLBAR_BTN = 'flex w-8 items-center justify-center text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90' +function requestWalkthroughPointerLock() { + const canvas = document.querySelector('[data-pascal-viewer-3d] canvas') + if (!canvas) return + + if (!canvas.hasAttribute('tabindex')) { + canvas.tabIndex = -1 + } + canvas.focus({ preventScroll: true }) + + if (document.pointerLockElement === canvas) return + + try { + canvas.requestPointerLock?.() + } catch { + return + } +} + function ToolbarTooltip({ children, label }: { children: ReactNode; label: string }) { return ( @@ -441,6 +460,15 @@ function DisplayMenu() { function WalkthroughButton() { const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode) const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode) + const handleClick = useCallback(() => { + if (isFirstPersonMode) { + setFirstPersonMode(false) + return + } + + flushSync(() => setFirstPersonMode(true)) + requestWalkthroughPointerLock() + }, [isFirstPersonMode, setFirstPersonMode]) return ( @@ -449,7 +477,7 @@ function WalkthroughButton() { TOOLBAR_BTN, isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20', )} - onClick={() => setFirstPersonMode(!isFirstPersonMode)} + onClick={handleClick} type="button" > diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index a6372283..4c9ae1a7 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -32,6 +32,7 @@ import { import { sfxEmitter } from '../../../lib/sfx-bus' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import useEditor from '../../../store/use-editor' +import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { useFloorplanRender } from '../floorplan-render-context' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' @@ -493,6 +494,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { event.preventDefault() event.stopPropagation() + suppressBoxSelectForPointer(event) const session = handler.start({ node, @@ -769,6 +771,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (!node) return event.preventDefault() event.stopPropagation() + suppressBoxSelectForPointer(event) sfxEmitter.emit('sfx:item-pick') setMovingNode(node as never) }} diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 0bcd06b3..980ae420 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -64,7 +64,7 @@ import { type FirstPersonColliderWorld, type FirstPersonSpawn, } from './first-person/build-collider-world' -import type { BVHEcctrlApi } from './first-person/bvh-ecctrl' +import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl' import BVHEcctrl from './first-person/bvh-ecctrl' const CAMERA_EYE_OFFSET = 0.45 @@ -79,7 +79,10 @@ const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 const VOID_FALL_RESPAWN_DEPTH = 12 -const keyboardMap = [ + +type MovementKeyName = Exclude + +const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [ { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] }, @@ -87,6 +90,36 @@ const keyboardMap = [ { name: 'jump', keys: ['Space'] }, { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, ] +const keyboardMap = movementKeyboardBindings +const movementKeyToName = new Map( + movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)), +) + +const inactiveMovementInput: MovementInput = { + backward: false, + forward: false, + jump: false, + leftward: false, + rightward: false, + run: false, +} + +function getMovementInputForKey(code: string, active: boolean): MovementInput | null { + const name = movementKeyToName.get(code) + return name ? ({ [name]: active } as MovementInput) : null +} + +function focusFirstPersonCanvas(canvas: HTMLCanvasElement) { + const activeElement = document.activeElement + if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) { + activeElement.blur() + } + + if (!canvas.hasAttribute('tabindex')) { + canvas.tabIndex = -1 + } + canvas.focus({ preventScroll: true }) +} const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) const cameraEuler = new Euler(0, 0, 0, 'YXZ') @@ -536,6 +569,8 @@ export const FirstPersonControls = () => { const selectedLevelId = useViewer((state) => state.selection.levelId) const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId)) const controllerRef = useRef(null) + const movementInputRef = useRef({ ...inactiveMovementInput }) + const hadPointerLockRef = useRef(false) const yawRef = useRef(0) const pitchRef = useRef(0) const interactableTargetRef = useRef(null) @@ -578,6 +613,13 @@ export const FirstPersonControls = () => { setIsElevatorRideLocked(locked) }, []) + const setControllerApi = useCallback((api: BVHEcctrlApi | null) => { + controllerRef.current = api + if (api) { + api.setMovement(movementInputRef.current) + } + }, []) + const resolveInteractableDoorId = useCallback((): AnyNodeId | null => { const nodes = useScene.getState().nodes camera.updateMatrixWorld(true) @@ -916,6 +958,14 @@ export const FirstPersonControls = () => { }) }, [camera, controllerStart, placedSpawn, world]) + useEffect(() => { + const canvas = gl.domElement + focusFirstPersonCanvas(canvas) + + const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas)) + return () => window.cancelAnimationFrame(frame) + }, [gl]) + useEffect(() => { const canvas = gl.domElement const handleMouseMove = (e: MouseEvent) => { @@ -946,14 +996,29 @@ export const FirstPersonControls = () => { toggleInteractableTarget() } + const handlePointerLockChange = () => { + const isLocked = document.pointerLockElement === canvas + if (isLocked) { + hadPointerLockRef.current = true + return + } + + if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) { + useEditor.getState().setFirstPersonMode(false) + } + } + + handlePointerLockChange() document.addEventListener('mousemove', handleMouseMove) document.addEventListener('click', handleClick) document.addEventListener('mousedown', handleMouseDown, true) + document.addEventListener('pointerlockchange', handlePointerLockChange) return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('click', handleClick) document.removeEventListener('mousedown', handleMouseDown, true) + document.removeEventListener('pointerlockchange', handlePointerLockChange) if (document.pointerLockElement === canvas) { document.exitPointerLock() } @@ -963,7 +1028,24 @@ export const FirstPersonControls = () => { useEffect(() => { const canvas = gl.domElement + const applyMovementKey = (event: KeyboardEvent, active: boolean) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return false + } + + const movement = getMovementInputForKey(event.code, active) + if (!movement) return false + + event.preventDefault() + Object.assign(movementInputRef.current, movement) + controllerRef.current?.setMovement(movement) + return true + } + const handleKeyDown = (event: KeyboardEvent) => { + const handledMovement = applyMovementKey(event, true) + if (handledMovement) return + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return } @@ -986,9 +1068,15 @@ export const FirstPersonControls = () => { } } + const handleKeyUp = (event: KeyboardEvent) => { + applyMovementKey(event, false) + } + document.addEventListener('keydown', handleKeyDown, true) + document.addEventListener('keyup', handleKeyUp, true) return () => { document.removeEventListener('keydown', handleKeyDown, true) + document.removeEventListener('keyup', handleKeyUp, true) } }, [closeInteractableTarget, gl, toggleInteractableTarget]) @@ -1308,7 +1396,7 @@ export const FirstPersonControls = () => { maxWalkSpeed={4} paused={isElevatorRideLocked} position={controllerStart.position} - ref={controllerRef} + ref={setControllerApi} /> )} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 6433940a..fb9c528e 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -887,6 +887,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ {/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 0fd56a5b..d8723198 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -43,6 +43,7 @@ import { EDITOR_LAYER } from '../../lib/constants' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { formatAngleRadians } from '../tools/shared/segment-angle' import { ARROW_COLOR, @@ -1169,6 +1170,7 @@ function TranslateArrow({ // 3D translate gizmo and the floating Move button behave identically. const activate = (event: ThreeEvent) => { event.stopPropagation() + suppressBoxSelectForPointer(event) sfxEmitter.emit('sfx:item-pick') useEditor.getState().setMovingNode(node as never) useViewer.getState().setSelection({ selectedIds: [] }) diff --git a/packages/editor/src/components/ui/action-menu/control-modes.tsx b/packages/editor/src/components/ui/action-menu/control-modes.tsx index 5cd84fcf..15fe4a17 100644 --- a/packages/editor/src/components/ui/action-menu/control-modes.tsx +++ b/packages/editor/src/components/ui/action-menu/control-modes.tsx @@ -1,15 +1,13 @@ 'use client' import { Icon } from '@iconify/react' -import { type LevelNode, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { type LucideIcon, Trash2 } from 'lucide-react' import Image from 'next/image' import { cn } from './../../../lib/utils' -import useEditor, { selectSiteFloorplanContext } from './../../../store/use-editor' +import useEditor from './../../../store/use-editor' import { ActionButton } from './action-button' -type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete' +type ControlId = 'select' | 'box-select' | 'zone' | 'delete' type ControlConfig = { id: ControlId @@ -32,13 +30,6 @@ const controls: ControlConfig[] = [ color: 'hover:bg-blue-500/20 hover:text-blue-400', activeColor: 'bg-blue-500/20 text-blue-400', }, - { - id: 'site-edit', - imageSrc: '/icons/site-flag.png', - label: 'Edit site', - color: 'hover:bg-white/5', - activeColor: 'bg-white/10 hover:bg-white/10', - }, { id: 'zone', imageSrc: '/icons/zone.png', @@ -65,47 +56,20 @@ export function ControlModes() { const setPhase = useEditor((state) => state.setPhase) const setStructureLayer = useEditor((state) => state.setStructureLayer) const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool) - const levelId = useViewer((s) => s.selection.levelId) - - // Only subscribe to the primitive `level` number — when walls are added to - // this level the object ref changes but this number doesn't, so Object.is - // dedupes and we avoid a re-render. - const levelIndex = useScene((state) => { - if (!levelId) return null - const node = state.nodes[levelId] - return node?.type === 'level' ? (node as LevelNode).level : null - }) const isSiteEditing = phase === 'site' - const isGroundFloor = levelIndex === 0 - const canEnterSiteEdit = isGroundFloor || isSiteEditing const structureLayer = useEditor((state) => state.structureLayer) const getIsActive = (id: ControlId): boolean => { - if (isSiteEditing) return id === 'site-edit' if (id === 'select') return mode === 'select' && selectionTool === 'click' if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee' - if (id === 'site-edit') return false if (id === 'zone') return mode === 'build' && phase === 'structure' && structureLayer === 'zones' return mode === id } const handleClick = (id: ControlId) => { - if (id === 'site-edit') { - if (isSiteEditing) { - // Toggle off → back to structure/select - setPhase('structure') - setMode('select') - setStructureLayer('elements') - } else if (isGroundFloor) { - useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null }) - selectSiteFloorplanContext() - } - return - } - // Exit site editing first if needed if (isSiteEditing) { setPhase('structure') @@ -136,36 +100,19 @@ export function ControlModes() { {controls.map((c) => { const ModeIcon = c.icon const isImageMode = Boolean(c.imageSrc) - const isSiteButton = c.id === 'site-edit' const isActive = getIsActive(c.id) - const isDisabled = isSiteButton && !canEnterSiteEdit return ( handleClick(c.id)} shortcut={c.shortcut} size="icon" @@ -176,13 +123,9 @@ export function ControlModes() { alt={c.label} className={cn( 'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200', - isSiteButton - ? isActive - ? 'opacity-100 grayscale-0' - : '' - : isActive - ? 'opacity-100 grayscale-0' - : 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0', + isActive + ? 'opacity-100 grayscale-0' + : 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0', )} height={28} src={c.imageSrc} diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index 1e461661..ee8ebb07 100644 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -26,6 +26,8 @@ import { ActionButton } from './action-button' const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif' const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] +const REFERENCES_EMPTY_TEXT = + 'Upload GLB meshes as scan references or blueprint images as guide references.' function formatGridSnapStep(step: GridSnapStep) { return step.toFixed(2) @@ -342,7 +344,7 @@ function GuidesControl() {
) : (
- No guide images on this level yet. + {REFERENCES_EMPTY_TEXT}
)} @@ -581,7 +583,7 @@ function ScansControl() { ) : (
- No scans on this level yet. + {REFERENCES_EMPTY_TEXT}
)} @@ -805,7 +807,7 @@ function ReferencesControl() { )}
Date: Wed, 10 Jun 2026 14:11:50 -0400 Subject: [PATCH 11/15] Fix spawn floorplan move handle --- .../nodes/src/spawn/__tests__/parity.test.ts | 2 + packages/nodes/src/spawn/definition.ts | 2 + packages/nodes/src/spawn/floorplan-move.ts | 39 +++++++++++++++++++ packages/nodes/src/spawn/floorplan.ts | 5 +++ 4 files changed, 48 insertions(+) create mode 100644 packages/nodes/src/spawn/floorplan-move.ts diff --git a/packages/nodes/src/spawn/__tests__/parity.test.ts b/packages/nodes/src/spawn/__tests__/parity.test.ts index 458b5ccd..05d3531d 100644 --- a/packages/nodes/src/spawn/__tests__/parity.test.ts +++ b/packages/nodes/src/spawn/__tests__/parity.test.ts @@ -31,6 +31,7 @@ describe('spawn definition', () => { expect(spawnDefinition.schemaVersion).toBe(1) expect(spawnDefinition.category).toBe('site') expect(spawnDefinition.schema).toBe(SpawnNode) + expect(typeof spawnDefinition.floorplanMoveTarget).toBe('function') }) test('defaults() returns a value that the schema accepts', () => { @@ -111,6 +112,7 @@ describe('spawn definition', () => { const flat = flattenFloorplan(geometry) expect(flat.some((entry) => entry.kind === 'path' && entry.stroke === '#818cf8')).toBe(true) + expect(flat.some((entry) => entry.kind === 'move-handle')).toBe(true) expect(flat.some((entry) => entry.kind === 'rotate-arrow')).toBe(true) }) diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index e5e7f41a..f76ecdc8 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -1,6 +1,7 @@ import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core' import { buildSpawnFloorplan } from './floorplan' import { spawnRotateAffordance } from './floorplan-affordances' +import { spawnFloorplanMoveTarget } from './floorplan-move' import { spawnParametrics } from './parametrics' import { SpawnNode } from './schema' @@ -92,6 +93,7 @@ export const spawnDefinition: NodeDefinition = { // delete. Legacy spawn click handlers in FloorplanNodeLayer become // dead code once Phase 6 cleanup removes the [] entries path. floorplan: buildSpawnFloorplan, + floorplanMoveTarget: spawnFloorplanMoveTarget, floorplanAffordances: { 'spawn-rotate': spawnRotateAffordance, }, diff --git a/packages/nodes/src/spawn/floorplan-move.ts b/packages/nodes/src/spawn/floorplan-move.ts new file mode 100644 index 00000000..fe6b1794 --- /dev/null +++ b/packages/nodes/src/spawn/floorplan-move.ts @@ -0,0 +1,39 @@ +import { + type AnyNodeId, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + type SpawnNode, + snapScalar, + useScene, +} from '@pascal-app/core' +import { getSegmentGridStep } from '@pascal-app/editor' + +export const spawnFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + const spawnId = node.id as AnyNodeId + const startY = node.position[1] + const originalPosition: [number, number, number] = [...node.position] + let lastPosition: [number, number, number] | null = null + + const session: FloorplanMoveTargetSession = { + affectedIds: [spawnId], + apply({ planPoint, modifiers }) { + const step = getSegmentGridStep() + const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step)) + const next: [number, number, number] = [snap(planPoint[0]), startY, snap(planPoint[1])] + + if (lastPosition && lastPosition[0] === next[0] && lastPosition[2] === next[2]) return + lastPosition = next + useScene.getState().updateNodes([{ id: spawnId, data: { position: next } }]) + }, + canCommit() { + if (!lastPosition) return false + return lastPosition[0] !== originalPosition[0] || lastPosition[2] !== originalPosition[2] + }, + commit() { + if (!lastPosition) return + useScene.getState().updateNodes([{ id: spawnId, data: { position: lastPosition } }]) + }, + } + + return session +} diff --git a/packages/nodes/src/spawn/floorplan.ts b/packages/nodes/src/spawn/floorplan.ts index ac72b1ab..6ccde308 100644 --- a/packages/nodes/src/spawn/floorplan.ts +++ b/packages/nodes/src/spawn/floorplan.ts @@ -113,6 +113,11 @@ export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): Floo ] if (isSelected) { + children.push({ + kind: 'move-handle', + point: [px, pz], + }) + const cornerLocalX = 0.34 + ROTATE_ARROW_CORNER_OFFSET const cornerLocalZ = 0.34 + ROTATE_ARROW_CORNER_OFFSET const [cornerX, cornerZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, planRotation) From 7879b83df35bdfaa9beba069df5d2368a8d6fa01 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 10 Jun 2026 14:21:14 -0400 Subject: [PATCH 12/15] feat: face-frame hosting for roof wall children + items on roof walls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wall-mounted items join doors/windows on roof-segment wall faces, and the storage model moves to FACE-LOCAL coordinates so hosted children track segment edits live. - Children store roofFace + position [u, v, z-from-mid-plane] with rotation 0 in-frame — the exact wall-child conventions (the wall volume's mid-plane lands on the nominal footprint). A shared derives segment pose + face frame from the live-override-merged segment: children follow resize handle drags in real time and never jump on commit. No re-anchor cascade needed — position is authoritative, the frame is derived. migrateNodes converts branch-era segment-local data. - Items: roofWallStrategy + roof:* handlers in the placement coordinator (surface 'roof-wall'), Shift free-place normalized with walls, ItemSystem wall-side push extended to segment hosts, correct 2D plan glyphs via face→segment→roof pose composition. The roof hit resolver + overlap guard moved to @pascal-app/editor (the coordinator lives there; nodes already depends on editor). - Cuts: subtractAccessoryCuts extracted and applied in BOTH the merged-shell and per-segment CSG paths (full edit mode / painted segments used to lose every hole), built from the CURRENT host geometry and live-effective children so holes follow segment and opening drags. - Handle rig: the grandparent portal now maps the node's world pose into the portal frame instead of composing parent+node registry poses — correct for any nesting (the face-frame group broke the old assumption), identical for walls. - Host-field hygiene: useDraftNode.commit/adopt and the window panel duplicate forward roofSegmentId/roofFace/wallId; every roof↔wall re-anchor clears and every revert restores them. Codex-reviewed (design consultation, adversarial rounds on the replaced cascade and on this refactor); frame conventions locked by unit tests. Record: private-editor plans/editor-roof-wall-openings.md. Co-Authored-By: Claude Fable 5 --- packages/core/src/schema/index.ts | 4 +- packages/core/src/schema/nodes/door.ts | 9 +- packages/core/src/schema/nodes/item.ts | 7 + .../schema/nodes/roof-segment-walls.test.ts | 78 ++++++ .../src/schema/nodes/roof-segment-walls.ts | 77 +++--- packages/core/src/schema/nodes/window.ts | 9 +- packages/core/src/store/use-scene.ts | 50 ++++ .../components/editor/node-arrow-handles.tsx | 30 ++- .../tools/item/placement-strategies.ts | 235 +++++++++++++++++- .../components/tools/item/placement-types.ts | 14 +- .../components/tools/item/use-draft-node.ts | 24 ++ .../tools/item/use-placement-coordinator.tsx | 156 ++++++++++++ packages/editor/src/index.tsx | 4 + .../src/lib}/roof-wall-hit.ts | 66 +++-- packages/nodes/src/door/definition.ts | 2 +- packages/nodes/src/door/floorplan-move.ts | 7 +- packages/nodes/src/door/move-tool.tsx | 50 ++-- packages/nodes/src/door/renderer.tsx | 25 +- packages/nodes/src/door/tool.tsx | 43 ++-- packages/nodes/src/item/definition.ts | 11 +- packages/nodes/src/item/floorplan-move.ts | 32 +++ packages/nodes/src/item/floorplan.ts | 28 +++ packages/nodes/src/item/move-tool.tsx | 14 ++ packages/nodes/src/item/renderer.tsx | 10 +- packages/nodes/src/shared/roof-face-host.tsx | 53 ++++ .../nodes/src/shared/roof-opening-host.ts | 54 ++-- .../nodes/src/shared/roof-wall-opening-cut.ts | 28 ++- packages/nodes/src/window/definition.ts | 2 +- packages/nodes/src/window/floorplan-move.ts | 7 +- packages/nodes/src/window/move-tool.tsx | 50 ++-- packages/nodes/src/window/panel.tsx | 2 + packages/nodes/src/window/renderer.tsx | 25 +- packages/nodes/src/window/tool.tsx | 43 ++-- .../viewer/src/systems/item/item-system.tsx | 18 +- .../viewer/src/systems/roof/roof-system.tsx | 179 +++++++------ 35 files changed, 1131 insertions(+), 315 deletions(-) create mode 100644 packages/core/src/schema/nodes/roof-segment-walls.test.ts rename packages/{nodes/src/shared => editor/src/lib}/roof-wall-hit.ts (70%) create mode 100644 packages/nodes/src/shared/roof-face-host.tsx diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 5d5c5a50..60823687 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -115,8 +115,8 @@ export { getMaxRoofRectWidthFromAnchor, getRoofSegmentWallFace, getRoofSegmentWallFaces, - getRoofWallFaceIdFromYaw, - roofWallFaceLocalToSegment, + getRoofWallFaceFrame, + roofFacePointToSegment, segmentPointToRoofWallFace, } from './nodes/roof-segment-walls' export { ScanNode } from './nodes/scan' diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 32bca4df..fd26ad68 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -47,10 +47,13 @@ export const DoorNode = BaseNode.extend({ side: z.enum(['front', 'back']).optional(), wallId: z.string().optional(), // Alternative host: a roof-segment's generated wall face (base wall - // under the roof or a coplanar gable end). When set, `position` is the - // opening center in SEGMENT-LOCAL coords on the outer wall plane and - // `rotation[1]` is the face yaw — see `roof-segment-walls.ts`. + // under the roof or a coplanar gable end). When set, `position` is + // FACE-LOCAL — [u along the face, v height, z from the wall mid-plane] + // — exactly the wall-child convention; the renderer mounts the node + // inside the face frame (`getRoofWallFaceFrame`), which is what makes + // hosted children track segment resizes live. roofSegmentId: z.string().optional(), + roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), // Overall dimensions width: z.number().default(0.9), diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index e37c3dc1..c03aa4b8 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -135,6 +135,13 @@ export const ItemNode = BaseNode.extend({ // Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side") wallId: z.string().optional(), wallT: z.number().optional(), // 0-1 parametric position along wall + // Alternative wall host: a roof-segment's generated wall face. When + // set, `position` is FACE-LOCAL — [u along the face, v = bottom edge, + // z from the wall mid-plane] — exactly the wall-child convention + // (ItemSystem's wall-side push applies the same way); the renderer + // mounts the node inside the face frame (`getRoofWallFaceFrame`). + roofSegmentId: z.string().optional(), + roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), // Denormalized references to collections this node belongs to collectionIds: z.array(z.custom()).optional(), diff --git a/packages/core/src/schema/nodes/roof-segment-walls.test.ts b/packages/core/src/schema/nodes/roof-segment-walls.test.ts new file mode 100644 index 00000000..7e4d7478 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-walls.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from './roof-segment' +import { + getRoofSegmentWallFace, + getRoofWallFaceFrame, + roofFacePointToSegment, + segmentPointToRoofWallFace, +} from './roof-segment-walls' + +function segment(overrides: Partial = {}): RoofSegmentNode { + return RoofSegmentNode.parse({ + id: 'rseg_test', + type: 'roof-segment', + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 2.6, + wallThickness: 0.1, + pitch: 40, + ...overrides, + }) +} + +describe('roof wall face frames', () => { + test('frame z = 0 lands on the nominal footprint (wall mid-plane)', () => { + const seg = segment() + // front face, u at the face middle, v = 1, mid-plane. + const point = roofFacePointToSegment(seg, 'front', [(8 + 0.1) / 2, 1, 0]) + expect(point[0]).toBeCloseTo(0) + expect(point[1]).toBeCloseTo(1) + expect(point[2]).toBeCloseTo(3) // depth / 2 — the footprint plane + }) + + test('frame +z is the outward normal on every face', () => { + const seg = segment() + for (const [faceId, axis, sign] of [ + ['front', 2, 1], + ['back', 2, -1], + ['right', 0, 1], + ['left', 0, -1], + ] as const) { + const onPlane = roofFacePointToSegment(seg, faceId, [1, 1, 0]) + const pushed = roofFacePointToSegment(seg, faceId, [1, 1, 0.5]) + expect(pushed[axis] - onPlane[axis]).toBeCloseTo(0.5 * sign) + // The other horizontal axis is unaffected by the push. + const other = axis === 2 ? 0 : 2 + expect(pushed[other] - onPlane[other]).toBeCloseTo(0) + } + }) + + test('face frame agrees with the hit resolver coordinates', () => { + const seg = segment() + // A point on the outer surface (z = +thickness/2 off the mid-plane) + // must read back with the same u/v and dist ≈ 0 off the outer plane. + const segLocal = roofFacePointToSegment(seg, 'right', [2.5, 1.25, 0.05]) + const { u, v, dist } = segmentPointToRoofWallFace(seg, 'right', segLocal) + expect(u).toBeCloseTo(2.5) + expect(v).toBeCloseTo(1.25) + expect(dist).toBeCloseTo(0) + }) + + test('resizing the segment moves the frame, not the stored coords', () => { + // The core live-tracking property: the same face-local point maps to + // the new plane after a depth change — children follow by re-render. + const before = roofFacePointToSegment(segment(), 'front', [2, 1, 0]) + const after = roofFacePointToSegment(segment({ depth: 8 }), 'front', [2, 1, 0]) + expect(before[2]).toBeCloseTo(3) + expect(after[2]).toBeCloseTo(4) + expect(after[1]).toBeCloseTo(before[1]) + }) + + test('frame yaw matches the face descriptor yaw', () => { + const seg = segment() + for (const faceId of ['front', 'back', 'right', 'left'] as const) { + expect(getRoofWallFaceFrame(seg, faceId).yaw).toBe(getRoofSegmentWallFace(seg, faceId).yaw) + } + }) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-walls.ts b/packages/core/src/schema/nodes/roof-segment-walls.ts index 54414ccf..7d783592 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -206,32 +206,6 @@ export function getRoofSegmentWallFaces(node: SegmentWallInputs): RoofSegmentWal })) } -/** - * Face coords → segment-local point on the outer wall plane. `inset` - * pushes the point inward along the face normal — openings store their - * center at the wall mid-plane (`inset = wallThickness / 2`) so the - * frame assembly centers inside the wall like on a regular wall host. - */ -export function roofWallFaceLocalToSegment( - node: SegmentWallInputs, - id: RoofWallFaceId, - u: number, - v: number, - inset = 0, -): [number, number, number] { - const { wV, dV } = getWallVolumeFrame(node) - switch (id) { - case 'front': - return [u - wV / 2, v, dV / 2 - inset] - case 'back': - return [wV / 2 - u, v, -dV / 2 + inset] - case 'right': - return [wV / 2 - inset, v, dV / 2 - u] - case 'left': - return [-wV / 2 + inset, v, u - dV / 2] - } -} - /** * Segment-local point → face coords. `dist` is the signed offset off the * outer wall plane along the face normal (0 = on the plane, positive = @@ -301,16 +275,47 @@ function getRectCenterConstraints( })) } -/** Face id for an opening's stored yaw (`rotation[1]`), or null. */ -export function getRoofWallFaceIdFromYaw(yaw: number): RoofWallFaceId | null { - const tau = Math.PI * 2 - const normalized = ((yaw % tau) + tau) % tau - const eps = 1e-3 - if (normalized < eps || tau - normalized < eps) return 'front' - if (Math.abs(normalized - Math.PI) < eps) return 'back' - if (Math.abs(normalized - Math.PI / 2) < eps) return 'right' - if (Math.abs(normalized - (3 * Math.PI) / 2) < eps) return 'left' - return null +/** + * The face's render frame in segment-local space: a group placed at + * `origin` and yawed by `yaw` maps face coords to segment space — + * frame X = U (along the face), frame Y = V (height), frame Z = the + * outward normal, with z = 0 on the WALL MID-PLANE. The mid-plane of + * the generated wall volume lands exactly on the nominal footprint + * (`±width/2` / `±depth/2`), so hosted children use the same position + * conventions as wall children (openings at z = 0, wall-side items + * pushed +thickness/2 at render time). Renderers derive this from the + * live-override-merged segment, which is what makes hosted children + * track segment edits live instead of jumping on commit. + */ +export function getRoofWallFaceFrame( + node: SegmentWallInputs, + id: RoofWallFaceId, +): { origin: [number, number, number]; yaw: number } { + const { wV, dV } = getWallVolumeFrame(node) + switch (id) { + case 'front': + return { origin: [-wV / 2, 0, node.depth / 2], yaw: FACE_YAWS.front } + case 'back': + return { origin: [wV / 2, 0, -node.depth / 2], yaw: FACE_YAWS.back } + case 'right': + return { origin: [node.width / 2, 0, dV / 2], yaw: FACE_YAWS.right } + case 'left': + return { origin: [-node.width / 2, 0, -dV / 2], yaw: FACE_YAWS.left } + } +} + +/** Face-frame point ([u, v, z-from-mid-plane]) → segment-local point. */ +export function roofFacePointToSegment( + node: SegmentWallInputs, + id: RoofWallFaceId, + point: [number, number, number], +): [number, number, number] { + const { origin, yaw } = getRoofWallFaceFrame(node, id) + const cos = Math.cos(yaw) + const sin = Math.sin(yaw) + const [u, v, z] = point + // rotation-y: +x → (cos, 0, -sin), +z → (sin, 0, cos) + return [origin[0] + u * cos + z * sin, origin[1] + v, origin[2] - u * sin + z * cos] } /** diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 307d4191..c5a1f5f0 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -29,10 +29,13 @@ export const WindowNode = BaseNode.extend({ // Wall reference wallId: z.string().optional(), // Alternative host: a roof-segment's generated wall face (base wall - // under the roof or a coplanar gable end). When set, `position` is the - // opening center in SEGMENT-LOCAL coords on the outer wall plane and - // `rotation[1]` is the face yaw — see `roof-segment-walls.ts`. + // under the roof or a coplanar gable end). When set, `position` is + // FACE-LOCAL — [u along the face, v height, z from the wall mid-plane] + // — exactly the wall-child convention; the renderer mounts the node + // inside the face frame (`getRoofWallFaceFrame`), which is what makes + // hosted children track segment resizes live. roofSegmentId: z.string().optional(), + roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), // Overall dimensions width: z.number().default(1.5), diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 7073857d..c802c9ea 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -14,6 +14,7 @@ import { type RoofSegmentNode, type RoofType, } from '../schema/nodes/roof-segment' +import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls' import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf' import { SiteNode } from '../schema/nodes/site' import { StairNode as StairNodeSchema } from '../schema/nodes/stair' @@ -539,6 +540,55 @@ function migrateNodes(nodes: Record): Record { patchedNodes[id] = { ...node, children: [] } as AnyNode } + // Roof-hosted wall children (door / window / item) originally stored + // SEGMENT-LOCAL positions with the face yaw in rotation[1]; the + // format moved to explicit `roofFace` + FACE-LOCAL coords so the + // renderer's face frame can track segment edits live. Convert in + // place: face from the old cardinal yaw, u/v from the outer-plane + // projection, z re-based from the outer plane to the wall mid-plane. + if ( + (node.type === 'door' || node.type === 'window' || node.type === 'item') && + typeof (node as { roofSegmentId?: unknown }).roofSegmentId === 'string' && + (node as { roofFace?: unknown }).roofFace === undefined + ) { + const current = patchedNodes[id] as AnyNode & { + roofSegmentId: string + position: [number, number, number] + rotation: [number, number, number] + } + const segment = patchedNodes[current.roofSegmentId] as + | (AnyNode & { wallThickness?: number }) + | undefined + if (segment?.type === 'roof-segment') { + const tau = Math.PI * 2 + const yaw = (((current.rotation?.[1] ?? 0) % tau) + tau) % tau + const eps = 1e-3 + const face = + yaw < eps || tau - yaw < eps + ? ('front' as const) + : Math.abs(yaw - Math.PI) < eps + ? ('back' as const) + : Math.abs(yaw - Math.PI / 2) < eps + ? ('right' as const) + : Math.abs(yaw - (3 * Math.PI) / 2) < eps + ? ('left' as const) + : null + if (face) { + const { u, v, dist } = segmentPointToRoofWallFace( + segment as never, + face, + current.position, + ) + patchedNodes[id] = { + ...current, + roofFace: face, + position: [u, v, dist + (segment.wallThickness ?? 0.1) / 2], + rotation: [0, 0, 0], + } as AnyNode + } + } + } + if (node.type === 'roof') { patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id]) } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index d8723198..7ffd7f99 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -38,6 +38,7 @@ import { Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' + import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import { createEditorApi } from '../../lib/editor-api' @@ -54,6 +55,9 @@ 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() export { ARROW_COLOR, @@ -276,14 +280,32 @@ function NodeArrowHandlesForNode({ // exclusion the wall arrow also goes without. useFrame(() => { + if (innerRef.current && innerRide && portalObject) { + // Grandparent mode: pose the rig by mapping the node's WORLD pose + // into the portal target's frame. Copying the parent + node + // registry poses (the previous approach) assumed the node mesh is + // a DIRECT child of the parent's registered object — roof-hosted + // openings break that with an intermediate face-frame group, which + // the world-relative mapping absorbs for free. For wall children + // the result is identical (portal⁻¹ ∘ node = wall.local ∘ node.local). + if (outerRef.current) { + outerRef.current.position.set(0, 0, 0) + outerRef.current.quaternion.identity() + } + portalObject.updateWorldMatrix(true, false) + innerRide.updateWorldMatrix(true, false) + _rigRelative.copy(portalObject.matrixWorld).invert().multiply(innerRide.matrixWorld) + _rigRelative.decompose( + innerRef.current.position, + innerRef.current.quaternion, + _rigScratchScale, + ) + return + } if (outerRef.current && outerRide) { outerRef.current.position.copy(outerRide.position) outerRef.current.quaternion.copy(outerRide.quaternion) } - if (innerRef.current && innerRide) { - innerRef.current.position.copy(innerRide.position) - innerRef.current.quaternion.copy(innerRide.quaternion) - } }) // Active-drag tracking. When a handle starts dragging, it claims its diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 047a8b02..67da8030 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,19 +6,27 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, + RoofNode, + RoofSegmentNode, + RoofWallFaceId, ShelfEvent, ShelfNode, WallEvent, WallNode, } from '@pascal-app/core' import { + clampRectToRoofWallFace, + getRoofSegmentWallFace, getScaledDimensions, isLowProfileItemSurface, nodeRegistry, + roofFacePointToSegment, sceneRegistry, useScene, } from '@pascal-app/core' import { Euler, Matrix3, Quaternion, Vector3 } from 'three' +import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit' import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' import { calculateCursorRotation, @@ -211,10 +219,13 @@ export const wallStrategy = { const adjustedY = validation.adjustedY ?? y return { - stateUpdate: { surface: 'wall', wallId: event.node.id }, + stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null }, nodeUpdate: { position: [x, adjustedY, z], parentId: event.node.id, + // The draft may arrive from a roof-segment wall face. + roofSegmentId: undefined, + roofFace: undefined, side, rotation: [0, itemRotation, 0], }, @@ -313,6 +324,8 @@ export const wallStrategy = { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], parentId: event.node.id, + roofSegmentId: undefined, + roofFace: undefined, side: ctx.draftItem.side, rotation: ctx.draftItem.rotation, metadata: stripTransient(ctx.draftItem.metadata), @@ -342,6 +355,223 @@ export const wallStrategy = { }, } +// ============================================================================ +// ROOF WALL STRATEGY +// ============================================================================ + +type RoofWallTarget = { + segment: RoofSegmentNode + faceId: RoofWallFaceId + faceYaw: number + /** Stored node position: segment-local, y = bottom edge. */ + position: [number, number, number] + /** Face-coord center of the placed rect (for the overlap guard). */ + centerU: number + centerV: number + width: number + height: number + cursorPosition: [number, number, number] + cursorRotationY: number +} + +/** + * Resolve a roof pointer event to an item placement on a segment wall + * face. Items snap u / bottom-v to the 0.5m grid, then the rect is + * clamped inside the face profile (sliding under the gable slopes). + * Position frame matches wall hosting: y anchors the BOTTOM edge; + * `wall-side` items mount on the outer surface, `wall` items center in + * the wall thickness. + * + * `shiftFree` mirrors the wall flow's Shift override (stubbed + * validators): the profile clamp is skipped, so the rect may overhang + * the face edges — placement follows the snapped cursor as-is. + */ +function resolveRoofWallTarget( + ctx: PlacementContext, + event: RoofEvent, + shiftFree = false, +): RoofWallTarget | null { + const attachTo = ctx.asset.attachTo + if (attachTo !== 'wall' && attachTo !== 'wall-side') return null + + const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object) + if (!hit) return null + + const rawDims = ctx.draftItem + ? getScaledDimensions(ctx.draftItem) + : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS) + const dims = getGridAlignedDimensions(rawDims, attachTo) + const [width, height] = dims + + const u = snapToHalf(hit.u) + const centerV = snapToHalf(hit.v) + height / 2 + const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height) + if (!fitted && !shiftFree) return null + const finalU = fitted?.u ?? u + const finalV = fitted?.v ?? centerV + + // FACE-LOCAL storage (z = 0 → wall mid-plane; ItemSystem pushes + // wall-side items to the outer surface, exactly like wall hosting). + // The renderer mounts the node inside the live face frame, so items + // track segment resizes without any re-anchoring. + const position: [number, number, number] = [finalU, finalV - height / 2, 0] + + const segObj = sceneRegistry.nodes.get(hit.segment.id) + if (!segObj) return null + segObj.updateWorldMatrix(true, false) + const segLocal = roofFacePointToSegment(hit.segment, hit.face.id, position) + const worldPos = segObj.localToWorld(new Vector3(segLocal[0], segLocal[1], segLocal[2])) + + const nodes = useScene.getState().nodes + const roof = hit.segment.parentId + ? (nodes[hit.segment.parentId as AnyNodeId] as RoofNode | undefined) + : undefined + + return { + segment: hit.segment, + faceId: hit.face.id, + faceYaw: hit.face.yaw, + position, + centerU: finalU, + centerV: finalV, + width, + height, + cursorPosition: [worldPos.x, worldPos.y, worldPos.z], + cursorRotationY: (roof?.rotation ?? 0) + (hit.segment.rotation ?? 0) + hit.face.yaw, + } +} + +/** Validation half of `checkCanPlace` for the roof-wall surface. */ +function canPlaceOnRoofWall(ctx: PlacementContext): boolean { + const segmentId = ctx.state.roofSegmentId + if (!(segmentId && ctx.draftItem)) return false + const segment = useScene.getState().nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined + if (segment?.type !== 'roof-segment') return false + const faceId = ctx.draftItem.roofFace + if (!faceId) return false + const face = getRoofSegmentWallFace(segment, faceId) + + const dims = getGridAlignedDimensions( + getScaledDimensions(ctx.draftItem), + ctx.draftItem.asset.attachTo, + ) + const [width, height] = dims + // gridPosition carries the stored FACE-LOCAL coords (u, bottom-v, z). + const u = ctx.gridPosition.x + const centerV = ctx.gridPosition.y + height / 2 + const clamped = clampRectToRoofWallFace(face, u, centerV, width, height) + if (!clamped || Math.abs(clamped.u - u) > 1e-3 || Math.abs(clamped.v - centerV) > 1e-3) { + return false + } + return !hasRoofFaceChildOverlap(segment, faceId, u, centerV, width, height, ctx.draftItem.id) +} + +export const roofWallStrategy = { + /** + * Handle roof:enter / first hover — transition onto a segment wall + * face. Returns null when the item doesn't wall-attach or the pointer + * isn't over a placeable face. + */ + enter(ctx: PlacementContext, event: RoofEvent, shiftFree = false): TransitionResult | null { + const target = resolveRoofWallTarget(ctx, event, shiftFree) + if (!target) return null + + return { + stateUpdate: { surface: 'roof-wall', roofSegmentId: target.segment.id, wallId: null }, + nodeUpdate: { + position: target.position, + parentId: target.segment.id, + roofSegmentId: target.segment.id, + roofFace: target.faceId, + wallId: undefined, + side: 'front', + rotation: [0, 0, 0], + }, + cursorRotationY: target.cursorRotationY, + gridPosition: target.position, + cursorPosition: target.cursorPosition, + stopPropagation: true, + } + }, + + /** + * Handle roof:move while on a segment wall face. Returns null when the + * pointer resolves to a DIFFERENT segment (the coordinator re-enters — + * segment transitions inside one roof never re-fire roof:enter) or to + * no placeable face. + */ + move(ctx: PlacementContext, event: RoofEvent, shiftFree = false): PlacementResult | null { + if (ctx.state.surface !== 'roof-wall') return null + if (!ctx.draftItem) return null + + const target = resolveRoofWallTarget(ctx, event, shiftFree) + if (!target) return null + if (target.segment.id !== ctx.state.roofSegmentId) return null + + return { + gridPosition: target.position, + cursorPosition: target.cursorPosition, + cursorRotationY: target.cursorRotationY, + nodeUpdate: { + position: target.position, + side: 'front', + rotation: [0, 0, 0], + roofFace: target.faceId, + }, + stopPropagation: true, + // Items don't cut the roof — no geometry rebuild needed. + dirtyNodeId: null, + } + }, + + /** + * Handle roof:click — commit placement on the segment wall face. + */ + click(ctx: PlacementContext, _event: RoofEvent, shiftFree = false): CommitResult | null { + if (ctx.state.surface !== 'roof-wall') return null + if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null + // Shift mirrors the wall flow's stubbed validators: skip profile-fit + // and overlap checks entirely. + if (!shiftFree && !canPlaceOnRoofWall(ctx)) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.state.roofSegmentId, + roofSegmentId: ctx.state.roofSegmentId, + roofFace: ctx.draftItem.roofFace, + wallId: undefined, + side: 'front', + rotation: [0, 0, 0], + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + /** + * Handle roof:leave — transition back to floor surface. + */ + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'roof-wall') return null + + return { + stateUpdate: { surface: 'floor', roofSegmentId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + roofSegmentId: undefined, + roofFace: undefined, + }, + cursorRotationY: 0, + gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + // ============================================================================ // CEILING STRATEGY // ============================================================================ @@ -794,6 +1024,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato } if (attachTo === 'wall' || attachTo === 'wall-side') { + if (ctx.state.surface === 'roof-wall') { + return canPlaceOnRoofWall(ctx) + } if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false return validators.canPlaceOnWall( ctx.levelId, diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index a3eccc11..33290743 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,13 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' +export type SurfaceType = + | 'floor' + | 'wall' + | 'roof-wall' + | 'ceiling' + | 'item-surface' + | 'shelf-surface' /** * Tracks which surface the draft item is currently on. @@ -21,6 +27,12 @@ export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf export interface PlacementState { surface: SurfaceType wallId: string | null + /** + * Active roof-segment when `surface === 'roof-wall'` — wall-attach + * items also host on the vertical wall faces a roof segment generates + * (base walls + coplanar gable ends). + */ + roofSegmentId: string | null ceilingId: string | null surfaceItemId: string | null /** diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index 67b72149..dfd752fb 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -15,6 +15,10 @@ interface OriginalState { rotation: [number, number, number] side: ItemNode['side'] parentId: string | null + // Roof-segment wall hosting — cleared/changed by surface transitions + // mid-move, so reverts must restore it alongside parentId. + roofSegmentId: ItemNode['roofSegmentId'] + roofFace: ItemNode['roofFace'] metadata: ItemNode['metadata'] } @@ -92,6 +96,8 @@ export function useDraftNode(): DraftNodeHandle { rotation: [...node.rotation] as [number, number, number], side: node.side, parentId: node.parentId, + roofSegmentId: node.roofSegmentId, + roofFace: node.roofFace, metadata: node.metadata, } @@ -121,6 +127,8 @@ export function useDraftNode(): DraftNodeHandle { rotation: original.rotation, side: original.side, parentId: original.parentId, + roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) @@ -133,6 +141,15 @@ export function useDraftNode(): DraftNodeHandle { side: updateProps.side ?? draft.side, metadata: updateProps.metadata ?? stripTransient(draft.metadata), parentId: parentId as string, + // Forward the roof host explicitly: strategies set it on every + // commit (segment id on a roof face, undefined elsewhere), and + // dropping it here strands the item in the roof frame without + // the segment transform. + roofSegmentId: updateProps.roofSegmentId, + roofFace: updateProps.roofFace, + // Only when the strategy decided about wallId (roof commits clear + // it) — floor/ceiling commits never managed the field. + ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), }) useScene.temporal.getState().pause() @@ -163,6 +180,11 @@ export function useDraftNode(): DraftNodeHandle { rotation: updateProps.rotation ?? draft.rotation, scale: updateProps.scale ?? draft.scale, side: updateProps.side ?? draft.side, + // Roof host — see the move-mode commit above for why this must be + // forwarded explicitly. + roofSegmentId: updateProps.roofSegmentId, + roofFace: updateProps.roofFace, + ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), metadata: updateProps.metadata ?? stripTransient(draft.metadata), }) useScene.getState().createNode(finalNode, parentId) @@ -207,6 +229,8 @@ export function useDraftNode(): DraftNodeHandle { rotation: original.rotation, side: original.side, parentId: original.parentId, + roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) 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 84bdfd2d..d91909d1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -10,6 +10,7 @@ import { getScaledDimensions, type ItemEvent, movingFootprintAnchors, + type RoofEvent, resolveLevelId, type ShelfEvent, sceneRegistry, @@ -56,6 +57,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofWallStrategy, shelfSurfaceStrategy, wallStrategy, } from './placement-strategies' @@ -213,6 +215,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea config.initialState ?? { surface: 'floor', wallId: null, + roofSegmentId: null, ceilingId: null, surfaceItemId: null, shelfId: null, @@ -413,6 +416,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea placementState.current = configRef.current.initialState ?? { surface: 'floor', wallId: null, + roofSegmentId: null, ceilingId: null, surfaceItemId: null, shelfId: null, @@ -987,6 +991,146 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Roof Wall Handlers ---- + // Wall-attach items also host on the vertical wall faces a roof + // segment generates (base walls + coplanar gable ends). Unlike walls, + // crossing between segments inside ONE roof never re-fires + // `roof:enter` (events come from the roof group), so the move handler + // re-enters whenever the strategy reports a segment change. + + const enterRoofWall = (event: RoofEvent): boolean => { + const result = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current) + if (!result) return false + + event.stopPropagation() + applyTransition(result) + + if (!draftNode.current) { + ensureDraft(result) + } else if (result.nodeUpdate.parentId) { + // Existing draft (move mode): reparent to the segment + useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate) + } + return true + } + + const onRoofWallEnter = (event: RoofEvent) => { + has3DPointerDrivenMoveRef.current = true + enterRoofWall(event) + } + + const onRoofWallMove = (event: RoofEvent) => { + releaseCommit = () => onRoofWallClick(event) + has3DPointerDrivenMoveRef.current = true + if (!cursorGroupRef.current) return + const ctx = getContext() + + if (ctx.state.surface !== 'roof-wall' || !draftNode.current) { + enterRoofWall(event) + return + } + + const result = roofWallStrategy.move(ctx, event, shiftFreeRef.current) + if (!result) { + // Different segment under the pointer (or no placeable face) — + // try a fresh enter; a null resolve leaves the draft where it is. + enterRoofWall(event) + return + } + + event.stopPropagation() + + const posChanged = + gridPosition.current.x !== result.gridPosition[0] || + gridPosition.current.y !== result.gridPosition[1] || + gridPosition.current.z !== result.gridPosition[2] + + if (posChanged) { + sfxEmitter.emit('sfx:grid-snap') + } + + gridPosition.current.set(...result.gridPosition) + const wc = worldToBuildingLocal(...result.cursorPosition) + cursorGroupRef.current.position.set(wc.x, wc.y, wc.z) + cursorGroupRef.current.rotation.y = result.cursorRotationY + + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + } + + const placeable = revalidate() + + if (draft && placeable) { + draft.position = result.gridPosition + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.copy(gridPosition.current) + // Wall-side items sit on the outer surface: mirror ItemSystem's + // 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 + ] + if (segment?.type === 'roof-segment') { + mesh.position.z = (segment.wallThickness ?? 0.1) / 2 + } + } + const rot = result.nodeUpdate?.rotation + if (rot) mesh.rotation.y = rot[1] + } + // The 2D floor-plan live frame is wall-local; a segment-local + // value would render garbage — clear instead of publishing. + useLiveTransforms.getState().clear(draft.id) + } + } + + const onRoofWallClick = (event: RoofEvent) => { + const result = roofWallStrategy.click(getContext(), event, shiftFreeRef.current) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + const enterResult = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current) + if (enterResult) { + applyTransition(enterResult) + } else { + revalidate() + } + } + } + + const onRoofWallLeave = (event: RoofEvent) => { + const result = roofWallStrategy.leave(getContext()) + if (!result) return + + event.stopPropagation() + + if (draftNode.isAdopted) { + // Move mode: keep draft alive, reparent to level + applyTransition(result) + const draft = draftNode.current + if (draft) { + useScene.getState().updateNode(draft.id, { + parentId: result.nodeUpdate.parentId as string, + roofSegmentId: undefined, + }) + } + } else { + // Create mode: destroy transient and reset state + draftNode.destroy() + Object.assign(placementState.current, result.stateUpdate) + } + } + // ---- Item Surface Handlers ---- const detachItemSurfaceToFloor = (event: ItemEvent) => { @@ -1499,6 +1643,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current if (!draft) return + // Roof-wall drafts live flat in the host face frame (yaw 0) — + // manual rotation would skew them off the wall plane. + if (placementState.current.surface === 'roof-wall') return + let rotationDelta = 0 if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey) rotationDelta = ROTATION_STEP @@ -1673,6 +1821,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) + emitter.on('roof:enter', onRoofWallEnter) + emitter.on('roof:move', onRoofWallMove) + emitter.on('roof:click', onRoofWallClick) + emitter.on('roof:leave', onRoofWallLeave) emitter.on('ceiling:enter', onCeilingEnter) emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) @@ -1704,6 +1856,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) + emitter.off('roof:enter', onRoofWallEnter) + emitter.off('roof:move', onRoofWallMove) + emitter.off('roof:click', onRoofWallClick) + emitter.off('roof:leave', onRoofWallLeave) emitter.off('ceiling:enter', onCeilingEnter) emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 874dea93..97523d1d 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -231,6 +231,10 @@ export { resolvePlanarCursorPosition, } from './lib/planar-cursor-placement' export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication' +// Roof wall-face hit resolution + overlap guard — shared by the +// kind-owned door / window tools in `@pascal-app/nodes` and the item +// placement coordinator's roof-wall strategy. +export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from './lib/roof-wall-hit' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' diff --git a/packages/nodes/src/shared/roof-wall-hit.ts b/packages/editor/src/lib/roof-wall-hit.ts similarity index 70% rename from packages/nodes/src/shared/roof-wall-hit.ts rename to packages/editor/src/lib/roof-wall-hit.ts index 2a186e56..e86f9be9 100644 --- a/packages/nodes/src/shared/roof-wall-hit.ts +++ b/packages/editor/src/lib/roof-wall-hit.ts @@ -1,9 +1,12 @@ import { type AnyNodeId, getRoofSegmentWallFaces, + getScaledDimensions, + type ItemNode, type RoofNode, type RoofSegmentNode, type RoofSegmentWallFace, + type RoofWallFaceId, sceneRegistry, segmentPointToRoofWallFace, useScene, @@ -42,6 +45,10 @@ const MAX_NORMAL_Y = 0.4 * merged-roof mesh (roof-local frame) or a painted segment mesh * (segment-local frame), so the normal is normalised through world space * here instead of trusting the event frame. + * + * Lives in `@pascal-app/editor` because both the kind-owned door/window + * tools (in `@pascal-app/nodes`, which depends on editor) and the item + * placement coordinator (in editor itself) consume it. */ export function resolveRoofWallHit( roof: RoofNode, @@ -100,14 +107,15 @@ export function resolveRoofWallHit( } /** - * Overlap guard for openings sharing a roof-segment wall face — the - * roof-host analogue of `hasWallChildOverlap`. Only door / window - * siblings on the same face are compared (other accessories live on the - * sloped surfaces). + * Overlap guard for nodes sharing a roof-segment wall face — the + * roof-host analogue of `hasWallChildOverlap`. Hosted children store + * FACE-LOCAL coords + an explicit `roofFace`, so siblings compare + * directly: doors/windows are center-anchored in v, wall items + * bottom-anchored. */ export function hasRoofFaceChildOverlap( segment: RoofSegmentNode, - face: RoofSegmentWallFace, + faceId: RoofWallFaceId, u: number, v: number, width: number, @@ -119,33 +127,37 @@ export function hasRoofFaceChildOverlap( const newRight = u + width / 2 const newBottom = v - height / 2 const newTop = v + height / 2 - // Sibling openings store their center at the wall mid-plane (inset by - // wallThickness / 2 from the outer plane this face measures from). - const sameFaceTolerance = (segment.wallThickness ?? 0.1) / 2 + PLANE_TOLERANCE for (const childId of segment.children ?? []) { if (childId === ignoreId) continue const child = nodes[childId as AnyNodeId] - if (!child || (child.type !== 'door' && child.type !== 'window')) continue - const opening = child as { - position: [number, number, number] - rotation: [number, number, number] - width: number - height: number + if (!child) continue + if ((child as { roofFace?: RoofWallFaceId }).roofFace !== faceId) continue + const position = (child as { position?: [number, number, number] }).position + if (!position) continue + + let childW: number + let childBottom: number + let childTop: number + if (child.type === 'door' || child.type === 'window') { + const opening = child as { width: number; height: number } + childW = opening.width + childBottom = position[1] - opening.height / 2 + childTop = position[1] + opening.height / 2 + } else if (child.type === 'item') { + const item = child as ItemNode + if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue + const [w, h] = getScaledDimensions(item) + childW = w + // Items anchor position[1] at their bottom edge. + childBottom = position[1] + childTop = position[1] + h + } else { + continue } - const { - u: childU, - v: childV, - dist, - } = segmentPointToRoofWallFace(segment, face.id, [ - opening.position[0], - opening.position[1], - opening.position[2], - ]) - // Same face = the opening's mid-plane center sits near this face. - if (Math.abs(dist) > sameFaceTolerance) continue - const xOverlap = newLeft < childU + opening.width / 2 && newRight > childU - opening.width / 2 - const yOverlap = newBottom < childV + opening.height / 2 && newTop > childV - opening.height / 2 + + const xOverlap = newLeft < position[0] + childW / 2 && newRight > position[0] - childW / 2 + const yOverlap = newBottom < childTop && newTop > childBottom if (xOverlap && yOverlap) return true } return false diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 906b1b3b..ae51da31 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -173,7 +173,7 @@ export const doorDefinition: NodeDefinition = { // re-derived from the surface under the cursor when a preset is // placed. Host apps strip these at preset-save time via // `getHostRefFields(def)`. - hostRefFields: ['wallId', 'roofSegmentId'], + hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], }, parametrics: doorParametrics, diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index 5ea4f71f..d6433a7f 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -55,10 +55,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) original: originalWall?.type === 'wall' ? projectWallLocalPointToPlan(originalWall, node.position[0]) - : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [ - node.position[0], - 0, - ]), + : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]), metadata: node.metadata, }) @@ -72,6 +69,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) parentId: string wallId: string roofSegmentId: undefined + roofFace: undefined } | null = null const session: FloorplanMoveTargetSession = { @@ -107,6 +105,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // Re-anchoring to a wall ends any roof-segment hosting; the // overlay's snapshot restores it if the move is reverted. roofSegmentId: undefined, + roofFace: undefined, } // Build the updates atomically — position + rotation + side + diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 701a5fbb..c28c029a 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -7,7 +7,7 @@ import { isCurvedWall, type RoofEvent, type RoofNode, - roofWallFaceLocalToSegment, + roofFacePointToSegment, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -19,7 +19,9 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, + hasRoofFaceChildOverlap, isValidWallSideFace, + resolveRoofWallHit, stripPlacementMetadataFlags, triggerSFX, useAlignmentGuides, @@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -68,6 +69,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts // must restore the roof host. roofSegmentId: movingDoorNode.roofSegmentId, + roofFace: movingDoorNode.roofFace, metadata: movingDoorNode.metadata, } @@ -219,6 +221,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: target.wallId, wallId: target.wallId, roofSegmentId: undefined, + roofFace: undefined, }) markWallDirty(currentWallId) currentWallId = target.wallId @@ -297,6 +300,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: target.wallId, parentId: target.wallId, roofSegmentId: undefined, + roofFace: undefined, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -308,6 +312,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) useScene.temporal.getState().resume() @@ -356,6 +361,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, }) if (original.parentId) markWallDirty(original.parentId) } @@ -390,34 +396,36 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { lockV: true }, ) if (!clamped) return null - const position = roofWallFaceLocalToSegment( - hit.segment, - hit.face.id, - clamped.u, - clamped.v, - (hit.segment.wallThickness ?? 0.1) / 2, - ) + // 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, + hit.face.id, clamped.u, clamped.v, movingDoorNode.width, movingDoorNode.height, movingDoorNode.id, ) - return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode } + 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) - roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + 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.yaw, + (target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw, target.valid, ) } @@ -432,18 +440,20 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => if (currentWallId !== target.hit.segment.id) { useScene.getState().updateNode(movingDoorNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', parentId: target.hit.segment.id, wallId: undefined, roofSegmentId: target.hit.segment.id, + roofFace: target.hit.face.id, }) markWallDirty(currentWallId) currentWallId = target.hit.segment.id } else { useScene.getState().updateNode(movingDoorNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], + roofFace: target.hit.face.id, }) } updateRoofCursor(target) @@ -467,10 +477,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const node = DoorNode.parse({ ...cloned, position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', wallId: undefined, roofSegmentId: segmentId, + roofFace: target.hit.face.id, parentId: segmentId, }) useScene.getState().createNode(node, segmentId as AnyNodeId) @@ -483,17 +494,19 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) useScene.temporal.getState().resume() useScene.getState().updateNode(movingDoorNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', parentId: segmentId, wallId: undefined, roofSegmentId: segmentId, + roofFace: target.hit.face.id, metadata: {}, }) @@ -531,6 +544,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, }) if (original.parentId) markWallDirty(original.parentId) } @@ -548,6 +562,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -584,6 +599,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) diff --git a/packages/nodes/src/door/renderer.tsx b/packages/nodes/src/door/renderer.tsx index 2925e78b..031328b9 100644 --- a/packages/nodes/src/door/renderer.tsx +++ b/packages/nodes/src/door/renderer.tsx @@ -1,15 +1,10 @@ 'use client' -import { - type AnyNodeId, - type DoorNode, - type RoofSegmentNode, - useRegistry, - useScene, -} from '@pascal-app/core' +import { type DoorNode, useRegistry, useScene } from '@pascal-app/core' import { useNodeEvents } from '@pascal-app/viewer' import { useLayoutEffect, useRef } from 'react' import { type Mesh, MeshBasicMaterial } from 'three' +import { RoofFaceHostFrame } from '../shared/roof-face-host' const doorHitboxMaterial = new MeshBasicMaterial({ visible: false }) @@ -23,16 +18,6 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { const handlers = useNodeEvents(node, 'door') const isTransient = !!(node.metadata as Record | null)?.isTransient - // Roof-hosted doors mount under the roof's `roof-elements` group (roof - // frame), so the host segment's transform is applied here — wall-hosted - // doors get it for free from the wall mesh they're nested in. - const segment = useScene((state) => - node.roofSegmentId - ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) - : undefined, - ) - if (node.roofSegmentId && segment?.type !== 'roof-segment') return null - const mesh = ( { ) - if (!segment) return mesh + if (!node.roofSegmentId) return mesh return ( - + {mesh} - + ) } diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index cf00b221..94ab2a1f 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -7,7 +7,7 @@ import { isCurvedWall, type RoofEvent, type RoofNode, - roofWallFaceLocalToSegment, + roofFacePointToSegment, sceneRegistry, spatialGridManager, useScene, @@ -18,7 +18,9 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, + hasRoofFaceChildOverlap, isValidWallSideFace, + resolveRoofWallHit, triggerSFX, useAlignmentGuides, } from '@pascal-app/editor' @@ -26,7 +28,6 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -226,6 +227,7 @@ const DoorTool: React.FC = () => { wallId: event.node.id, // The draft may arrive from a roof-segment face hover. roofSegmentId: undefined, + roofFace: undefined, }) } } @@ -374,23 +376,20 @@ const DoorTool: React.FC = () => { lockV: true, }) if (!clamped) return null - const position = roofWallFaceLocalToSegment( - hit.segment, - hit.face.id, - clamped.u, - clamped.v, - (hit.segment.wallThickness ?? 0.1) / 2, - ) + // 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, + hit.face.id, clamped.u, clamped.v, width, height, draftRef.current?.id, ) - return { hit, position, yaw: hit.face.yaw, valid } + return { hit, position, valid } } const updateRoofCursor = ( @@ -400,11 +399,16 @@ const DoorTool: React.FC = () => { const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) if (!segObj) return segObj.updateWorldMatrix(true, false) - roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + 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.yaw, + (roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw, target.valid, ) } @@ -420,20 +424,22 @@ const DoorTool: React.FC = () => { } return } - const { hit, position, yaw } = target + const { hit, position } = target if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft() if (draftRef.current) { useScene.getState().updateNode(draftRef.current.id, { position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], + roofFace: hit.face.id, }) } else { const node = DoorNode.parse({ position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], side: 'front', roofSegmentId: hit.segment.id, + roofFace: hit.face.id, parentId: hit.segment.id, metadata: { isTransient: true }, }) @@ -448,7 +454,7 @@ const DoorTool: React.FC = () => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) if (!target?.valid) return - const { hit, position, yaw } = target + const { hit, position } = target const draft = draftRef.current draftRef.current = null @@ -464,9 +470,10 @@ const DoorTool: React.FC = () => { const node = DoorNode.parse({ name: `Door ${doorCount + 1}`, position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], side: 'front', roofSegmentId: hit.segment.id, + roofFace: hit.face.id, parentId: hit.segment.id, width: draft.width, height: draft.height, diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index e7de994c..2f9dc6fe 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -206,12 +206,13 @@ export const itemDefinition: NodeDefinition = { // siblings of GLB items inside the unified `items` table. // // Items can be hosted on walls (assets with `attachTo: 'wall'`) - // via `wallId` + `wallT`. When a composition that includes a - // wall-hosted item is saved as a preset (a sconce, a hanging - // shelf, etc.), the host app strips these via `getHostRefFields(def)` - // so the descendant re-attaches against the new wall geometry at + // via `wallId` + `wallT`, or on a roof-segment wall face via + // `roofSegmentId`. When a composition that includes a wall-hosted + // item is saved as a preset (a sconce, a hanging shelf, etc.), the + // host app strips these via `getHostRefFields(def)` so the + // descendant re-attaches against the new host geometry at // placement time. - hostRefFields: ['wallId', 'wallT'], + hostRefFields: ['wallId', 'wallT', 'roofSegmentId', 'roofFace'], // Floor items get lifted by slabs underneath via the generic // ``. Wall- / ceiling-attached items live in // their parent's local frame and skip the lift via `applies`. diff --git a/packages/nodes/src/item/floorplan-move.ts b/packages/nodes/src/item/floorplan-move.ts index a8c1b747..d8747045 100644 --- a/packages/nodes/src/item/floorplan-move.ts +++ b/packages/nodes/src/item/floorplan-move.ts @@ -5,9 +5,12 @@ import { collectAlignmentAnchors, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + getRoofWallFaceFrame, getScaledDimensions, type ItemNode, movingFootprintAnchors, + type RoofSegmentNode, + roofFacePointToSegment, useScene, } from '@pascal-app/core' import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor' @@ -95,6 +98,31 @@ function resolveItemPlanTransform( point: [parentTransform.point[0] + offsetX, parentTransform.point[1] + offsetZ], rotation: parentTransform.rotation + localRotation, } + } else if (parent?.type === 'roof-segment') { + // Roof-hosted wall item: FACE-LOCAL position mapped through the face + // frame, then composed through the segment's and roof's yaw + + // position into level-local plan coords — without this the drag seed + // jumps off the roof at move start. + const segment = parent as RoofSegmentNode + const roof = segment.parentId + ? (nodes[segment.parentId as AnyNodeId] as + | (AnyNode & { position: [number, number, number]; rotation: number }) + | undefined) + : undefined + if (roof?.type === 'roof' && item.roofFace) { + const frame = getRoofWallFaceFrame(segment, item.roofFace) + const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position) + const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0) + const [rx, rz] = rotateVec( + sx + segment.position[0], + sz + segment.position[2], + roof.rotation ?? 0, + ) + result = { + point: [rx + roof.position[0], rz + roof.position[2]], + rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + localRotation, + } + } } cache.set(item.id as AnyNodeId, result) @@ -208,6 +236,10 @@ function buildWallItemSession( rotation: [0, hit.itemRotation, 0], side: hit.side, parentId: hit.wall.id, + // Re-anchoring to a wall ends any roof-segment hosting; the + // overlay's snapshot restores it if the move is reverted. + roofSegmentId: undefined, + roofFace: undefined, }, }, ]) diff --git a/packages/nodes/src/item/floorplan.ts b/packages/nodes/src/item/floorplan.ts index b07b9221..2a01fa4b 100644 --- a/packages/nodes/src/item/floorplan.ts +++ b/packages/nodes/src/item/floorplan.ts @@ -4,8 +4,11 @@ import { type FloorplanGeometry, type FloorplanPoint, type GeometryContext, + getRoofWallFaceFrame, getScaledDimensions, type ItemNode, + type RoofSegmentNode, + roofFacePointToSegment, useLiveTransforms, } from '@pascal-app/core' @@ -112,6 +115,31 @@ function resolveItemTransform( y: shelfZ + offsetY, rotation: shelfRotationY + localRotation, } + } else if (parentNode?.type === 'roof-segment') { + // Roof-hosted wall item: FACE-LOCAL position mapped through the face + // frame, then composed through the segment's and parent roof's poses + // into level-local plan coords. + const segment = parentNode as RoofSegmentNode + const roof = segment.parentId + ? (ctx.resolve(segment.parentId as AnyNodeId) as + | (AnyNode & { position: [number, number, number]; rotation: number }) + | undefined) + : undefined + if (roof?.type === 'roof' && item.roofFace) { + const frame = getRoofWallFaceFrame(segment, item.roofFace) + const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position) + const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0) + const [rx, rz] = rotateVec( + sx + segment.position[0], + sz + segment.position[2], + roof.rotation ?? 0, + ) + result = { + x: rx + roof.position[0], + y: rz + roof.position[2], + rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + localRotation, + } + } } else { // Level / slab / ceiling parent — item.position is level-local. result = { diff --git a/packages/nodes/src/item/move-tool.tsx b/packages/nodes/src/item/move-tool.tsx index 236047da..1e7cfd3b 100644 --- a/packages/nodes/src/item/move-tool.tsx +++ b/packages/nodes/src/item/move-tool.tsx @@ -38,9 +38,20 @@ import { Vector3 } from 'three' function getInitialState(node: ItemNode): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { + if (node.roofSegmentId) { + return { + surface: 'roof-wall', + wallId: null, + roofSegmentId: node.roofSegmentId, + ceilingId: null, + surfaceItemId: null, + shelfId: null, + } + } return { surface: 'wall', wallId: node.parentId, + roofSegmentId: null, ceilingId: null, surfaceItemId: null, shelfId: null, @@ -50,6 +61,7 @@ function getInitialState(node: ItemNode): PlacementState { return { surface: 'ceiling', wallId: null, + roofSegmentId: null, ceilingId: node.parentId, surfaceItemId: null, shelfId: null, @@ -58,6 +70,7 @@ function getInitialState(node: ItemNode): PlacementState { return { surface: 'floor', wallId: null, + roofSegmentId: null, ceilingId: null, surfaceItemId: null, shelfId: null, @@ -81,6 +94,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) { ? { surface: 'floor', wallId: null, + roofSegmentId: null, ceilingId: null, surfaceItemId: null, shelfId: null, diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 5814a986..35b5cc15 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -33,6 +33,7 @@ import { Suspense, useEffect, useMemo, useRef } from 'react' import type { AnimationAction, Group, Material, Mesh } from 'three' import { MathUtils } from 'three' import { positionLocal, smoothstep, time } from 'three/tsl' +import { RoofFaceHostFrame } from '../shared/roof-face-host' type MutableMaterial = Material & { depthTest?: boolean @@ -92,7 +93,7 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => { [storeNode, liveOverrides], ) - return ( + const content = ( }> }> @@ -104,6 +105,13 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => { ))} ) + + if (!node.roofSegmentId) return content + return ( + + {content} + + ) } const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract()) diff --git a/packages/nodes/src/shared/roof-face-host.tsx b/packages/nodes/src/shared/roof-face-host.tsx new file mode 100644 index 00000000..1083b015 --- /dev/null +++ b/packages/nodes/src/shared/roof-face-host.tsx @@ -0,0 +1,53 @@ +'use client' + +import { + type AnyNodeId, + getRoofWallFaceFrame, + type RoofSegmentNode, + type RoofWallFaceId, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { type ReactNode, useMemo } from 'react' + +/** + * Mounts a roof-hosted wall child inside its host face frame. Children + * of roof segments render under the roof's `roof-elements` group (roof + * frame); this wrapper applies the segment transform plus the face + * frame, both derived from the LIVE-override-merged segment — hosted + * nodes therefore track segment handle drags in real time instead of + * jumping to their new spot on commit. Inside the frame, children use + * plain wall-child position conventions ([u, v, z-from-mid-plane]). + */ +export function RoofFaceHostFrame({ + roofSegmentId, + roofFace, + children, +}: { + roofSegmentId: string + roofFace: RoofWallFaceId | undefined + children: ReactNode +}) { + const storeSegment = useScene( + (state) => state.nodes[roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined, + ) + const liveOverride = useLiveNodeOverrides((s) => s.get(roofSegmentId as AnyNodeId)) + const segment = useMemo( + () => + storeSegment && liveOverride + ? ({ ...storeSegment, ...liveOverride } as RoofSegmentNode) + : storeSegment, + [storeSegment, liveOverride], + ) + + if (!segment || segment.type !== 'roof-segment' || !roofFace) return null + const frame = getRoofWallFaceFrame(segment, roofFace) + + return ( + + + {children} + + + ) +} diff --git a/packages/nodes/src/shared/roof-opening-host.ts b/packages/nodes/src/shared/roof-opening-host.ts index 8b604e84..7ac87e7a 100644 --- a/packages/nodes/src/shared/roof-opening-host.ts +++ b/packages/nodes/src/shared/roof-opening-host.ts @@ -1,23 +1,29 @@ -import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '@pascal-app/core' +import type { + AnyNode, + AnyNodeId, + RoofNode, + RoofSegmentNode, + RoofWallFaceId, +} from '@pascal-app/core' import { getMaxRoofRectHeightFromAnchor, getMaxRoofRectWidthFromAnchor, getRoofSegmentWallFace, - getRoofWallFaceIdFromYaw, - segmentPointToRoofWallFace, + roofFacePointToSegment, } from '@pascal-app/core' /** * Host-side helpers for openings (door / window) hosted on a roof-segment * wall face: resize-handle limits derived from the face profile, and the - * plan-space anchors the 2D floor-plan move path needs. + * plan-space anchors the 2D floor-plan move path needs. Hosted children + * store FACE-LOCAL coords ([u, v, z-from-mid-plane]) + `roofFace`. */ type RoofHostedOpening = { roofSegmentId?: string + roofFace?: RoofWallFaceId parentId: string | null position: [number, number, number] - rotation: [number, number, number] width: number height: number } @@ -25,14 +31,10 @@ type RoofHostedOpening = { type SceneReader = { get: (id: AnyNodeId) => unknown } function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) { - if (!node.roofSegmentId) return null + if (!(node.roofSegmentId && node.roofFace)) return null const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined if (!segment || segment.type !== 'roof-segment') return null - const faceId = getRoofWallFaceIdFromYaw(node.rotation[1]) - if (!faceId) return null - const face = getRoofSegmentWallFace(segment, faceId) - const { u, v } = segmentPointToRoofWallFace(segment, faceId, node.position) - return { segment, face, u, v } + return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) } } /** @@ -47,8 +49,8 @@ export function readRoofFaceWidthMax( ): number | null { const host = resolveHostFace(node, scene) if (!host) return null - const anchorU = host.u - (growSign * node.width) / 2 - return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, host.v, node.height) + const anchorU = node.position[0] - (growSign * node.width) / 2 + return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, node.position[1], node.height) } /** @@ -63,8 +65,8 @@ export function readRoofFaceHeightMax( ): number | null { const host = resolveHostFace(node, scene) if (!host) return null - const anchorV = host.v - (growSign * node.height) / 2 - return getMaxRoofRectHeightFromAnchor(host.face, host.u, node.width, anchorV, growSign) + const anchorV = node.position[1] - (growSign * node.height) / 2 + return getMaxRoofRectHeightFromAnchor(host.face, node.position[0], node.width, anchorV, growSign) } /** @@ -72,7 +74,7 @@ export function readRoofFaceHeightMax( * level). Null when the parent chain isn't roof-shaped. */ export function getRoofHostedOpeningLevelId( - node: RoofHostedOpening, + node: { parentId: string | null }, nodes: Record, ): AnyNodeId | null { const segment = node.parentId ? nodes[node.parentId] : undefined @@ -83,15 +85,20 @@ export function getRoofHostedOpeningLevelId( } /** - * Level-plan [x, z] of a roof-hosted opening — its segment-local center - * composed through the segment's and roof's yaw + position. + * Level-plan [x, z] of a roof-hosted node — its face-local center mapped + * through the face frame, then composed through the segment's and roof's + * yaw + position. */ export function getRoofHostedOpeningPlanPoint( - node: RoofHostedOpening, + node: { + parentId: string | null + roofFace?: RoofWallFaceId + position: [number, number, number] + }, nodes: Record, ): [number, number] | null { const segment = node.parentId ? (nodes[node.parentId] as RoofSegmentNode | undefined) : undefined - if (segment?.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment' || !node.roofFace) return null const roof = segment.parentId ? (nodes[segment.parentId] as RoofNode | undefined) : undefined if (roof?.type !== 'roof') return null @@ -100,7 +107,12 @@ export function getRoofHostedOpeningPlanPoint( -x * Math.sin(yaw) + z * Math.cos(yaw), ] - const [sx, sz] = rotate(node.position[0], node.position[2], segment.rotation ?? 0) + const segLocal = roofFacePointToSegment(segment, node.roofFace, [ + node.position[0], + node.position[1], + node.position[2], + ]) + const [sx, sz] = rotate(segLocal[0], segLocal[2], segment.rotation ?? 0) const segX = sx + segment.position[0] const segZ = sz + segment.position[2] const [rx, rz] = rotate(segX, segZ, roof.rotation ?? 0) diff --git a/packages/nodes/src/shared/roof-wall-opening-cut.ts b/packages/nodes/src/shared/roof-wall-opening-cut.ts index f9e3d525..41f189bf 100644 --- a/packages/nodes/src/shared/roof-wall-opening-cut.ts +++ b/packages/nodes/src/shared/roof-wall-opening-cut.ts @@ -1,28 +1,29 @@ -import type { RoofSegmentNode } from '@pascal-app/core' +import type { RoofSegmentNode, RoofWallFaceId } from '@pascal-app/core' +import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core' import * as THREE from 'three' type RoofWallOpening = { roofSegmentId?: string + roofFace?: RoofWallFaceId position: [number, number, number] - rotation: [number, number, number] width: number height: number } /** * CSG cut for a door / window hosted on a roof-segment wall face - * (`capabilities.roofAccessory.buildCut`). A box through the wall plane, - * oriented by the opening's face yaw, in segment-local coords — the - * roof-merge loop subtracts it from the segment's wall brush. + * (`capabilities.roofAccessory.buildCut`). A box through the wall + * mid-plane, derived from the CURRENT host geometry (the opening stores + * face-local coords), so the hole follows segment resizes for free. * - * Returns null for wall-hosted openings (no `roofSegmentId`): their cut - * is handled by the wall system's own cutout pipeline. + * Returns null for wall-hosted openings: their cut is handled by the + * wall system's own cutout pipeline. */ export function buildRoofWallOpeningCut( node: RoofWallOpening, hostSegment: RoofSegmentNode, ): THREE.BufferGeometry | null { - if (!node.roofSegmentId) return null + if (!node.roofSegmentId || !node.roofFace) return null const wallThickness = hostSegment.wallThickness ?? 0.1 // Through the wall both ways, but well short of the rake/eave overhang @@ -34,9 +35,16 @@ export function buildRoofWallOpeningCut( const bottom = node.position[1] - node.height / 2 const bottomPad = bottom < 0.005 ? 0.02 : 0 + const center = roofFacePointToSegment(hostSegment, node.roofFace, [ + node.position[0], + node.position[1], + 0, + ]) + const { yaw } = getRoofWallFaceFrame(hostSegment, node.roofFace) + const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth) geo.translate(0, -bottomPad / 2, 0) - geo.rotateY(node.rotation[1] ?? 0) - geo.translate(node.position[0], node.position[1], node.position[2]) + geo.rotateY(yaw) + geo.translate(center[0], center[1], center[2]) return geo } diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 8d5bbb79..83bbd778 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -161,7 +161,7 @@ export const windowDefinition: NodeDefinition = { }, // `wallId` / `roofSegmentId` are re-derived from the surface under // the cursor at preset placement time — see door for the pattern. - hostRefFields: ['wallId', 'roofSegmentId'], + hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], }, parametrics: windowParametrics, diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index bcd7c82e..24a9c8a0 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -48,10 +48,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod original: originalWall?.type === 'wall' ? projectWallLocalPointToPlan(originalWall, node.position[0]) - : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [ - node.position[0], - 0, - ]), + : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]), metadata: node.metadata, }) @@ -69,6 +66,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod parentId: string wallId: string roofSegmentId: undefined + roofFace: undefined } | null = null const session: FloorplanMoveTargetSession = { @@ -109,6 +107,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // Re-anchoring to a wall ends any roof-segment hosting; the // overlay's snapshot restores it if the move is reverted. roofSegmentId: undefined, + roofFace: undefined, } useScene.getState().updateNodes([ diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 15936a6d..ed776e76 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -6,7 +6,7 @@ import { isCurvedWall, type RoofEvent, type RoofNode, - roofWallFaceLocalToSegment, + roofFacePointToSegment, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -19,7 +19,9 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, + hasRoofFaceChildOverlap, isValidWallSideFace, + resolveRoofWallHit, snapToHalf, triggerSFX, useAlignmentGuides, @@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -81,6 +82,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts // must restore the roof host. roofSegmentId: movingWindowNode.roofSegmentId, + roofFace: movingWindowNode.roofFace, metadata: movingWindowNode.metadata, } @@ -242,6 +244,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: target.wallId, wallId: target.wallId, roofSegmentId: undefined, + roofFace: undefined, }) markWallDirty(currentWallId) currentWallId = target.wallId @@ -328,6 +331,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: target.wallId, parentId: target.wallId, roofSegmentId: undefined, + roofFace: undefined, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -341,6 +345,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) useScene.temporal.getState().resume() @@ -390,6 +395,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, }) if (original.parentId) markWallDirty(original.parentId) } @@ -426,34 +432,36 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode movingWindowNode.height, ) if (!clamped) return null - const position = roofWallFaceLocalToSegment( - hit.segment, - hit.face.id, - clamped.u, - clamped.v, - (hit.segment.wallThickness ?? 0.1) / 2, - ) + // 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, + hit.face.id, clamped.u, clamped.v, movingWindowNode.width, movingWindowNode.height, movingWindowNode.id, ) - return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode } + 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) - roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + 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.yaw, + (target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw, target.valid, ) } @@ -468,18 +476,20 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode if (currentWallId !== target.hit.segment.id) { useScene.getState().updateNode(movingWindowNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', parentId: target.hit.segment.id, wallId: undefined, roofSegmentId: target.hit.segment.id, + roofFace: target.hit.face.id, }) markWallDirty(currentWallId) currentWallId = target.hit.segment.id } else { useScene.getState().updateNode(movingWindowNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], + roofFace: target.hit.face.id, }) } updateRoofCursor(target) @@ -507,10 +517,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const node = WindowNode.parse({ ...cloned, position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', wallId: undefined, roofSegmentId: segmentId, + roofFace: target.hit.face.id, parentId: segmentId, }) useScene.getState().createNode(node, segmentId as AnyNodeId) @@ -523,17 +534,19 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) useScene.temporal.getState().resume() useScene.getState().updateNode(movingWindowNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', parentId: segmentId, wallId: undefined, roofSegmentId: segmentId, + roofFace: target.hit.face.id, metadata: {}, }) @@ -571,6 +584,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, }) if (original.parentId) markWallDirty(original.parentId) } @@ -588,6 +602,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -625,6 +640,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index bdcfc82a..b1bd3b8f 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -215,6 +215,8 @@ export default function WindowPanel() { rotation: [...node.rotation] as [number, number, number], side: node.side, wallId: node.wallId, + roofSegmentId: node.roofSegmentId, + roofFace: node.roofFace, parentId: node.parentId, width: node.width, height: node.height, diff --git a/packages/nodes/src/window/renderer.tsx b/packages/nodes/src/window/renderer.tsx index 412ba635..0c670b28 100644 --- a/packages/nodes/src/window/renderer.tsx +++ b/packages/nodes/src/window/renderer.tsx @@ -1,12 +1,6 @@ 'use client' -import { - type AnyNodeId, - type RoofSegmentNode, - useRegistry, - useScene, - type WindowNode, -} from '@pascal-app/core' +import { useRegistry, useScene, type WindowNode } from '@pascal-app/core' import { createMaterial, DEFAULT_WINDOW_MATERIAL, @@ -15,6 +9,7 @@ import { } from '@pascal-app/viewer' import { useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' +import { RoofFaceHostFrame } from '../shared/roof-face-host' export const WindowRenderer = ({ node }: { node: WindowNode }) => { const ref = useRef(null!) @@ -39,16 +34,6 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { node.material?.texture, ]) - // Roof-hosted windows mount under the roof's `roof-elements` group (roof - // frame), so the host segment's transform is applied here — wall-hosted - // windows get it for free from the wall mesh they're nested in. - const segment = useScene((state) => - node.roofSegmentId - ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) - : undefined, - ) - if (node.roofSegmentId && segment?.type !== 'roof-segment') return null - const mesh = ( { ) - if (!segment) return mesh + if (!node.roofSegmentId) return mesh return ( - + {mesh} - + ) } diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 44da2d34..124a9dcc 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -6,7 +6,7 @@ import { isCurvedWall, type RoofEvent, type RoofNode, - roofWallFaceLocalToSegment, + roofFacePointToSegment, sceneRegistry, spatialGridManager, useScene, @@ -18,7 +18,9 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, + hasRoofFaceChildOverlap, isValidWallSideFace, + resolveRoofWallHit, snapToHalf, triggerSFX, useAlignmentGuides, @@ -27,7 +29,6 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -234,6 +235,7 @@ const WindowTool: React.FC = () => { wallId: event.node.id, // The draft may arrive from a roof-segment face hover. roofSegmentId: undefined, + roofFace: undefined, }) } } @@ -384,23 +386,20 @@ const WindowTool: React.FC = () => { // it down under the gable slopes when needed. const clamped = clampRectToRoofWallFace(hit.face, hit.u, snapToHalf(hit.v), width, height) if (!clamped) return null - const position = roofWallFaceLocalToSegment( - hit.segment, - hit.face.id, - clamped.u, - clamped.v, - (hit.segment.wallThickness ?? 0.1) / 2, - ) + // 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, + hit.face.id, clamped.u, clamped.v, width, height, draftRef.current?.id, ) - return { hit, position, yaw: hit.face.yaw, valid } + return { hit, position, valid } } const updateRoofCursor = ( @@ -410,11 +409,16 @@ const WindowTool: React.FC = () => { const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) if (!segObj) return segObj.updateWorldMatrix(true, false) - roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + 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.yaw, + (roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw, target.valid, ) } @@ -430,20 +434,22 @@ const WindowTool: React.FC = () => { } return } - const { hit, position, yaw } = target + const { hit, position } = target if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft() if (draftRef.current) { useScene.getState().updateNode(draftRef.current.id, { position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], + roofFace: hit.face.id, }) } else { const node = WindowNode.parse({ position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], side: 'front', roofSegmentId: hit.segment.id, + roofFace: hit.face.id, parentId: hit.segment.id, metadata: { isTransient: true }, }) @@ -458,7 +464,7 @@ const WindowTool: React.FC = () => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) if (!target?.valid) return - const { hit, position, yaw } = target + const { hit, position } = target const draft = draftRef.current draftRef.current = null @@ -474,9 +480,10 @@ const WindowTool: React.FC = () => { const node = WindowNode.parse({ name: `Window ${windowCount + 1}`, position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], side: 'front', roofSegmentId: hit.segment.id, + roofFace: hit.face.id, parentId: hit.segment.id, width: draft.width, height: draft.height, diff --git a/packages/viewer/src/systems/item/item-system.tsx b/packages/viewer/src/systems/item/item-system.tsx index 8b86454e..25cb03b4 100644 --- a/packages/viewer/src/systems/item/item-system.tsx +++ b/packages/viewer/src/systems/item/item-system.tsx @@ -36,12 +36,20 @@ export const ItemSystem = () => { if (!mesh) return if (item.asset.attachTo === 'wall-side') { - // Wall-attached item: offset Z by half the parent wall's thickness - const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined - if (parentWall && parentWall.type === 'wall') { - const wallThickness = (parentWall as WallNode).thickness ?? 0.1 + // Wall-attached item: offset Z by half the host wall's thickness. + // Roof-segment wall faces share the convention — the face frame's + // z = 0 is the wall mid-plane, so the same push lands the item on + // the outer surface. + const parent = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined + const thickness = + parent?.type === 'wall' + ? ((parent as WallNode).thickness ?? 0.1) + : parent?.type === 'roof-segment' + ? (parent.wallThickness ?? 0.1) + : undefined + if (thickness !== undefined) { const side = item.side === 'front' ? 1 : -1 - mesh.position.z = (wallThickness / 2) * side + mesh.position.z = (thickness / 2) * side } } diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 57e74b18..1a5d7991 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -113,7 +113,10 @@ export const RoofSystem = () => { // Kinds with `cascadesViaHostSegment` (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) { + if ( + def?.capabilities?.roofAccessory && + !def.capabilities.roofAccessory.cascadesViaHostSegment + ) { const segId = (node as { roofSegmentId?: string }).roofSegmentId const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined if (seg?.parentId) { @@ -145,7 +148,7 @@ export const RoofSystem = () => { mesh.parent?.name === 'segments-wrapper' && mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) { - updateRoofSegmentGeometry(effectiveSegment, mesh) + updateRoofSegmentGeometry(effectiveSegment, mesh, nodes) segmentsProcessed++ } else if (isVisible && !revealOnly) { return // Over budget — keep dirty, process next frame @@ -231,8 +234,12 @@ export const RoofSystem = () => { // GEOMETRY GENERATION // ============================================================================ -function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) { - const newGeo = generateRoofSegmentGeometry(node) +function updateRoofSegmentGeometry( + node: RoofSegmentNode, + mesh: THREE.Mesh, + nodes?: Record, +) { + const newGeo = generateRoofSegmentGeometry(node, nodes) mesh.geometry.dispose() mesh.geometry = newGeo @@ -242,6 +249,89 @@ function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) { mesh.rotation.y = node.rotation } +/** + * Subtract every hosted accessory cut (`capabilities.roofAccessory. + * buildCut`) from a segment's brushes, in SEGMENT-LOCAL space. Shared by + * the merged-shell path AND the per-segment path (full edit mode / + * painted segments) — without the latter, selecting a segment used to + * swap the merged shell for uncut per-segment meshes and every door / + * window / skylight hole vanished until deselect. Children are read + * live-effective so an in-flight handle drag carves the live hole. + * Registry-driven so the viewer never names a kind. + */ +function subtractAccessoryCuts( + brushes: { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush }, + segment: RoofSegmentNode, + nodes: Record, +) { + let workingShin = brushes.shinSlab + let workingDeck = brushes.deckSlab + let workingWall = brushes.wallBrush + for (const childElemId of segment.children ?? []) { + const storedChild = nodes[childElemId as AnyNodeId] + if (!storedChild) continue + const childElem = getEffectiveNode(storedChild) + const meta = + typeof childElem.metadata === 'object' && childElem.metadata !== null + ? (childElem.metadata as Record) + : undefined + if (meta?.isTransient) continue + + const childDef = nodeRegistry.get(childElem.type) + const buildCut = childDef?.capabilities?.roofAccessory?.buildCut + if (!buildCut) continue + + const cutGeo = buildCut(childElem, segment) + if (!cutGeo) continue + + // Wrap the kind-emitted geometry in a Brush. Kinds return raw + // shapes; the viewer welds (mandatory after rotations leave + // duplicated verts), attaches a single material group, and + // builds the bounds tree — keeping kind code free of + // three-bvh-csg / three-mesh-bvh imports. + const welded = mergeVertices(cutGeo, 1e-4) + cutGeo.dispose() + const idxCount = welded.getIndex()?.count ?? 0 + if (idxCount === 0) { + welded.dispose() + continue + } + welded.clearGroups() + welded.addGroup(0, idxCount, 0) + welded.computeVertexNormals() + computeGeometryBoundsTree(welded) + const cut = new Brush(welded, dummyMats[0]) + cut.updateMatrixWorld() + + const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all' + try { + if (cutScope !== 'wall') { + const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush + workingShin.geometry.dispose() + prepareBrushForCSG(nextShin) + workingShin = nextShin + + const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush + workingDeck.geometry.dispose() + prepareBrushForCSG(nextDeck) + workingDeck = nextDeck + } + + const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush + workingWall.geometry.dispose() + prepareBrushForCSG(nextWall) + workingWall = nextWall + } catch (e) { + console.error(`[${childElem.type}] cut CSG failed:`, e) + } finally { + cut.geometry.dispose() + } + } + brushes.shinSlab = workingShin + brushes.deckSlab = workingDeck + brushes.wallBrush = workingWall +} + function updateMergedRoofGeometry( roofNode: RoofNode, group: THREE.Group, @@ -282,77 +372,7 @@ function updateMergedRoofGeometry( const brushes = getRoofSegmentBrushes(child) if (!brushes) continue - // Per-child cuts in SEGMENT-LOCAL space: subtract every accessory - // that contributes a cut (declares - // `capabilities.roofAccessory.buildCut`) from shin / deck / wall - // before we accumulate. Mirrors roof-system v1 — the cut is built - // in segment-local, then carved out before the segment transform - // stacks on. Registry-driven so the viewer never names a kind. - let workingShin = brushes.shinSlab - let workingDeck = brushes.deckSlab - let workingWall = brushes.wallBrush - for (const childElemId of child.children ?? []) { - const childElem = nodes[childElemId as AnyNodeId] - if (!childElem) continue - const meta = - typeof childElem.metadata === 'object' && childElem.metadata !== null - ? (childElem.metadata as Record) - : undefined - if (meta?.isTransient) continue - - const childDef = nodeRegistry.get(childElem.type) - const buildCut = childDef?.capabilities?.roofAccessory?.buildCut - if (!buildCut) continue - - const cutGeo = buildCut(childElem, child) - if (!cutGeo) continue - - // Wrap the kind-emitted geometry in a Brush. Kinds return raw - // shapes; the viewer welds (mandatory after rotations leave - // duplicated verts), attaches a single material group, and - // builds the bounds tree — keeping kind code free of - // three-bvh-csg / three-mesh-bvh imports. - const welded = mergeVertices(cutGeo, 1e-4) - cutGeo.dispose() - const idxCount = welded.getIndex()?.count ?? 0 - if (idxCount === 0) { - welded.dispose() - continue - } - welded.clearGroups() - welded.addGroup(0, idxCount, 0) - welded.computeVertexNormals() - computeGeometryBoundsTree(welded) - const cut = new Brush(welded, dummyMats[0]) - cut.updateMatrixWorld() - - const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all' - try { - if (cutScope !== 'wall') { - const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush - workingShin.geometry.dispose() - prepareBrushForCSG(nextShin) - workingShin = nextShin - - const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush - workingDeck.geometry.dispose() - prepareBrushForCSG(nextDeck) - workingDeck = nextDeck - } - - const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush - workingWall.geometry.dispose() - prepareBrushForCSG(nextWall) - workingWall = nextWall - } catch (e) { - console.error(`[${childElem.type}] cut CSG failed:`, e) - } finally { - cut.geometry.dispose() - } - } - brushes.shinSlab = workingShin - brushes.deckSlab = workingDeck - brushes.wallBrush = workingWall + subtractAccessoryCuts(brushes, child, nodes) _matrix.compose( _position.set(child.position[0], child.position[1], child.position[2]), @@ -813,13 +833,20 @@ export function getRoofSegmentBrushes( return null } -export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.BufferGeometry { +export function generateRoofSegmentGeometry( + node: RoofSegmentNode, + nodes?: Record, +): THREE.BufferGeometry { const brushes = getRoofSegmentBrushes(node) if (!brushes) { // Fallback: simple box return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth) } + if (nodes) { + subtractAccessoryCuts(brushes, node, nodes) + } + const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes let resultGeo = new THREE.BufferGeometry() From 07d01b18e735444275d0db291e4dd2ef7161fd70 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 10 Jun 2026 14:21:24 -0400 Subject: [PATCH 13/15] feat(editor): action menu tracks live geometry changes Floating action menu re-derives its anchor when the selected node's geometry rebuilds (position/index attribute versions as the key) and accounts for roof height via getActiveRoofHeight + effective nodes. --- .../editor/floating-action-menu.tsx | 79 ++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index e5a74395..aa09c288 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -10,6 +10,8 @@ import { ElevatorNode, FenceNode, generateId, + getActiveRoofHeight, + getEffectiveNode, getWallCurveLength, getWallThickness, ItemNode, @@ -111,6 +113,49 @@ function getMenuYOffset(node: AnyNode | null): number { return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT } +function getAttributeVersion( + attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute | null | undefined, +): number { + return attribute && 'version' in attribute && typeof attribute.version === 'number' + ? attribute.version + : 0 +} + +function getObjectGeometryKey(object: THREE.Object3D): string { + const parts: string[] = [] + object.traverse((child) => { + const geometry = (child as Partial).geometry + if (!geometry) return + + parts.push( + [ + geometry.id, + getAttributeVersion(geometry.getAttribute('position')), + getAttributeVersion(geometry.getIndex()), + ].join(':'), + ) + }) + return parts.join('|') +} + +function setNodeDerivedMenuAnchor( + node: AnyNode, + object: THREE.Object3D, + target: THREE.Vector3, +): boolean { + if (node.type !== 'roof-segment') return false + + const visualTop = + node.wallHeight + + getActiveRoofHeight(node) + + Math.max(0, node.deckThickness ?? 0) + + Math.max(0, node.shingleThickness ?? 0) + + target.set(0, visualTop, 0).applyMatrix4(object.matrixWorld) + target.y += getMenuYOffset(node) + return true +} + // Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the // pill reads sensibly before an explicit height / thickness is set. const FENCE_DEFAULT_HEIGHT = 1.8 @@ -171,9 +216,14 @@ export function FloatingActionMenu() { const anchorRef = useRef(new THREE.Vector3()) const hasAnchorRef = useRef(false) const lastMatrixRef = useRef(new THREE.Matrix4()) - const lastAnchorKeyRef = useRef<{ id: string | null; node: AnyNode | null }>({ + const lastAnchorKeyRef = useRef<{ + id: string | null + node: AnyNode | null + geometryKey: string | null + }>({ id: null, node: null, + geometryKey: null, }) // Only show for single selection of specific types @@ -218,7 +268,7 @@ export function FloatingActionMenu() { }) useFrame((state) => { - if (!(selectedId && isValidType && groupRef.current)) return + if (!(selectedId && node && isValidType && groupRef.current)) return // Scale the HTML menu with camera zoom (ortho) or inverse distance // (perspective) so it feels anchored to the world, clamped on both ends @@ -253,6 +303,8 @@ export function FloatingActionMenu() { const obj = sceneRegistry.nodes.get(selectedId) if (obj) { + obj.updateWorldMatrix(true, false) + // Recompute the anchor only when the object genuinely changes — // reselected, moved (its own world matrix changed), or resized // (a fresh store node on commit, or a live override / handle drag @@ -261,21 +313,28 @@ export function FloatingActionMenu() { // holds still. const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null const dragActive = activeHandleDrag?.nodeId === selectedId + const effectiveNode = getEffectiveNode(node) + const geometryKey = getObjectGeometryKey(obj) const selectionChanged = lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld) + const geometryChanged = lastAnchorKeyRef.current.geometryKey !== geometryKey - if (selectionChanged || matrixChanged || overrideActive || dragActive) { - const box = new THREE.Box3().setFromObject(obj) - if (!box.isEmpty()) { - const center = box.getCenter(new THREE.Vector3()) - // Position above the object. Per-type offsets clear each kind's - // in-world chrome (height-resize arrows, measurement labels). - anchorRef.current.set(center.x, box.max.y + getMenuYOffset(node), center.z) + if (selectionChanged || matrixChanged || geometryChanged || overrideActive || dragActive) { + if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) { + const box = new THREE.Box3().setFromObject(obj) + if (!box.isEmpty()) { + const center = box.getCenter(new THREE.Vector3()) + // Position above the object. Per-type offsets clear each kind's + // in-world chrome (height-resize arrows, measurement labels). + anchorRef.current.set(center.x, box.max.y + getMenuYOffset(effectiveNode), center.z) + hasAnchorRef.current = true + } + } else { hasAnchorRef.current = true } lastMatrixRef.current.copy(obj.matrixWorld) - lastAnchorKeyRef.current = { id: selectedId, node } + lastAnchorKeyRef.current = { id: selectedId, node, geometryKey } } if (hasAnchorRef.current) { From aa3b0ef75819676833759a3b48d6a3d4d8fb1f40 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 10 Jun 2026 14:33:13 -0400 Subject: [PATCH 14/15] refactor: release-review cleanup for roof wall openings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/editor/components/build-tab.tsx | 43 ++--- packages/core/src/registry/index.ts | 1 - packages/core/src/registry/registry.ts | 22 --- packages/core/src/registry/types.ts | 14 +- packages/core/src/utils/clone-scene-graph.ts | 13 ++ .../components/editor/node-arrow-handles.tsx | 1 + .../tools/item/placement-strategies.ts | 2 +- .../tools/item/use-placement-coordinator.tsx | 5 +- packages/nodes/src/door/definition.ts | 4 +- packages/nodes/src/door/move-tool.tsx | 159 ++++++---------- packages/nodes/src/door/tool.tsx | 113 ++++-------- .../src/shared/roof-wall-opening-placement.ts | 113 ++++++++++++ packages/nodes/src/window/definition.ts | 4 +- packages/nodes/src/window/move-tool.tsx | 171 ++++++------------ packages/nodes/src/window/tool.tsx | 115 ++++-------- .../viewer/src/systems/roof/roof-system.tsx | 4 +- 16 files changed, 349 insertions(+), 435 deletions(-) create mode 100644 packages/nodes/src/shared/roof-wall-opening-placement.ts 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 From ee7bb39605706e049ceb5071dd13b3707d4caf11 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 10 Jun 2026 14:36:19 -0400 Subject: [PATCH 15/15] style: biome format/import-order for refactored tool files Co-Authored-By: Claude Fable 5 --- packages/nodes/src/door/move-tool.tsx | 3 +-- packages/nodes/src/door/tool.tsx | 3 +-- packages/nodes/src/window/move-tool.tsx | 3 +-- packages/nodes/src/window/tool.tsx | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 4a29c564..2062f464 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -29,8 +29,8 @@ import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { getRoofWallOpeningCursorPose, - resolveRoofWallOpeningTarget, type RoofWallOpeningTarget, + resolveRoofWallOpeningTarget, } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -42,7 +42,6 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) - const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const cursorGroupRef = useRef(null!) diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index be0421b9..c562a0f0 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -26,8 +26,8 @@ import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three import { LineBasicNodeMaterial } from 'three/webgpu' import { getRoofWallOpeningCursorPose, - resolveRoofWallOpeningTarget, type RoofWallOpeningTarget, + resolveRoofWallOpeningTarget, } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -39,7 +39,6 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) - /** * Door tool — places DoorNodes on walls and on roof-segment wall faces * (the generated base walls under a roof, including coplanar gable ends). diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 1f8cb9fe..5da0e2fa 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -30,8 +30,8 @@ import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { getRoofWallOpeningCursorPose, - resolveRoofWallOpeningTarget, type RoofWallOpeningTarget, + resolveRoofWallOpeningTarget, } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -43,7 +43,6 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) - /** * Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool. * diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 84d00ebc..2075d962 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -27,8 +27,8 @@ import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three import { LineBasicNodeMaterial } from 'three/webgpu' import { getRoofWallOpeningCursorPose, - resolveRoofWallOpeningTarget, type RoofWallOpeningTarget, + resolveRoofWallOpeningTarget, } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -41,7 +41,6 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) - /** * Window tool — places WindowNodes on walls and on roof-segment wall * faces (the generated base walls under a roof, including coplanar gable