diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 2d69058b..5824255c 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -116,6 +116,14 @@ export type LinearResizeHandle = { anchor: HandleAnchor currentValue: (node: N) => number apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + /** + * Optional per-tick hook fired while this handle is being dragged, with the + * live (in-progress, override-merged) node. A pure side-channel for transient + * feedback — doors/windows use it to publish proximity / sill guides for the + * edge being resized. The return value is ignored; the resize itself is driven + * by `apply`. + */ + onDrag?: (node: N, sceneApi: SceneApi) => void /** * Cross-node redirect. By default the drag's live override + the * committed write both land on the SELECTED node. When this returns diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 5dba030f..515dc048 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -47,6 +47,7 @@ import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useEditor from '../../store/use-editor' +import useOpeningGuides from '../../store/use-opening-guides' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { formatAngleRadians } from '../tools/shared/segment-angle' import { @@ -593,6 +594,9 @@ function LinearArrow({ // floating dimension pill (via `activeHandleDrag`) and its own in-world // chip is suppressed — matches the wall height handle. const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined + // Optional per-tick feedback hook (doors/windows publish proximity/sill guides + // for the edge being resized); cleared when the drag ends. + const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined const placementSceneApi = useMemo(() => createSceneApi(useScene), []) const basePosition = descriptor.placement.position(node, placementSceneApi) // `freezeOffset` (in node-local frame) cancels the mesh's `position` @@ -675,6 +679,7 @@ function LinearArrow({ if (measureLabel) { useEditor.getState().setActiveHandleDrag(null) } + if (onDrag) useOpeningGuides.getState().clear() }, move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => { const currentPointer = @@ -690,7 +695,10 @@ function LinearArrow({ ? snapScalar(rawNext, gridSnapStep) : rawNext const next = Math.min(maxBound, Math.max(minBound, snappedNext)) - return descriptor.apply(initialNode as never, next, sceneApi) as Partial + const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial + // Let the kind publish live guides for the edge being resized. + onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi) + return patch }, } }, diff --git a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx index f6ba7d3f..fb7299f6 100644 --- a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx +++ b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx @@ -43,8 +43,8 @@ const mid = (a: OpeningGuideVec3, b: OpeningGuideVec3): OpeningGuideVec3 => [ /** * Wall-plane proximity / alignment guides for the 3D editor — the spatial twin * of the floor-plan placement dimensions + equal-spacing badges. Subscribes to - * `useOpeningGuides` (published by the door/window move tools each drag tick) and - * draws sill/head + edge-proximity dimensions, a sill-alignment line, and + * `useOpeningGuides` (published by the door/window move, placement, and resize + * interactions each drag tick) and draws sill/head + edge-proximity dimensions, a sill-alignment line, and * equal-spacing badges. Coordinates are already in the move tool's render frame * (the producer reuses the cursor's `wallLocalToWorld`, so they share the cursor's * building-local frame), so this layer mounts beside `Alignment3DGuideLayer` and diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index ba1b1d16..9cf3d2a0 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -6,6 +6,7 @@ import type { RoofSegmentNode, WallNode, } from '@pascal-app/core' +import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { scaleHandleHeight } from './door-math' @@ -56,6 +57,7 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor return readWallLength(n, scene) }, currentValue: (n) => n.width, + onDrag: (node) => publishOpeningResizeGuides(node, false), apply: (initial, newWidth) => { // Anchored edge stays fixed in wall-local coords. Door rotation is // applied by the inner ride group (the renderer mounts a nested @@ -98,6 +100,7 @@ function doorHeightHandle(): HandleDescriptor { return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom) }, currentValue: (n) => n.height, + onDrag: (node) => publishOpeningResizeGuides(node, false), apply: (initial, newHeight) => { const bottom = initial.position[1] - initial.height / 2 // Scale the handle so it tracks the door instead of staying glued to a diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index e5ae2dcf..d3799bab 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -28,7 +28,10 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { clearOpeningGuides3D, publishOpeningGuides3D } from '../shared/opening-guides-runtime' +import { + clearOpeningGuides3D, + publishOpeningGuidesForWallEvent, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, @@ -256,7 +259,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => target.valid, ) - publishOpeningGuides3D({ + publishOpeningGuidesForWallEvent({ wall: target.wallNode, movingId: movingDoorNode.id, centerS: target.clampedX, @@ -265,15 +268,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => height: movingDoorNode.height, // Doors sit on the floor — no sill/head or vertical alignment guides. includeVertical: false, - nodes: useScene.getState().nodes, - toWorld: (s, y) => - wallLocalToWorld( - target.wallNode, - s, - y, - getLevelYOffset(), - getSlabElevation(target.event), - ), + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(target.event), }) } diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 0aa4c8c1..78dda6e7 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -25,6 +25,10 @@ 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 { + clearOpeningGuides3D, + publishOpeningGuidesForWallEvent, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, @@ -86,6 +90,7 @@ const DoorTool: React.FC = () => { const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } // Alignment candidates — anchors of every alignable object; refreshed @@ -110,18 +115,21 @@ const DoorTool: React.FC = () => { const [x, y, z] = event.localPosition updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const showRoofFallbackCursor = (event: RoofEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const showWallFallbackCursor = (event: WallEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const onWallEnter = (event: WallEvent) => { @@ -191,6 +199,18 @@ const DoorTool: React.FC = () => { cursorRotation, valid, ) + + publishOpeningGuidesForWallEvent({ + wall: event.node, + movingId: node.id, + centerS: clampedX, + centerY: clampedY, + width, + height, + includeVertical: false, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(event), + }) event.stopPropagation() } @@ -298,6 +318,20 @@ const DoorTool: React.FC = () => { cursorRotation, valid, ) + + if (draftRef.current) { + publishOpeningGuidesForWallEvent({ + wall: event.node, + movingId: draftRef.current.id, + centerS: clampedX, + centerY: clampedY, + width, + height, + includeVertical: false, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(event), + }) + } event.stopPropagation() } @@ -386,6 +420,7 @@ const DoorTool: React.FC = () => { triggerSFX('sfx:structure-build') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() + clearOpeningGuides3D() event.stopPropagation() } @@ -444,6 +479,8 @@ const DoorTool: React.FC = () => { useScene.getState().createNode(node, segment.id as AnyNodeId) draftRef.current = node } + // Opening guides are wall-specific; clear them while over a roof face. + clearOpeningGuides3D() updateRoofCursor(target, event.node as RoofNode) event.stopPropagation() } @@ -533,6 +570,7 @@ const DoorTool: React.FC = () => { destroyDraft() hideCursor() useAlignmentGuides.getState().clear() + clearOpeningGuides3D() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts index 9b5db582..dcc22b49 100644 --- a/packages/nodes/src/shared/opening-guides-runtime.ts +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -9,6 +9,9 @@ import { computeOpeningGuides, detectVerticalAlignment, type OpeningSpan, + sceneRegistry, + spatialGridManager, + useScene, type WallNode, } from '@pascal-app/core' import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor' @@ -159,3 +162,89 @@ export function publishOpeningGuides3D(args: { export function clearOpeningGuides3D(): void { useOpeningGuides.getState().clear() } + +/** Wall-local (s along the wall, y above the wall base) → the move tool's render + * frame, given the level Y offset + slab elevation. Shared by the wall-event + * publisher (which already has them) and the resize publisher (which derives + * them from the scene). Same frame as `wallLocalToWorld`. */ +function makeWallToWorld(wall: WallNode, levelYOffset: number, slabElevation: number): ToWorld { + const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return (s, y) => [ + wall.start[0] + s * cos, + slabElevation + y + levelYOffset, + wall.start[1] + s * sin, + ] +} + +/** Like {@link makeWallToWorld} but derives the level Y + slab elevation from the + * scene, for callers without a wall event — i.e. the resize handles. */ +export function wallToWorld(wall: WallNode): ToWorld { + const levelId = wall.parentId as AnyNodeId | undefined + const levelYOffset = levelId ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0) : 0 + const slabElevation = spatialGridManager.getSlabElevationForWall( + wall.parentId ?? '', + wall.start, + wall.end, + ) + return makeWallToWorld(wall, levelYOffset, slabElevation) +} + +/** + * Publish 3D opening guides for an opening being placed or moved on a wall via a + * wall event. The caller passes the level Y + slab elevation it already computed + * for the drag cursor, so the guides share the cursor's frame exactly — the one + * place the door/window move + placement tools publish from. + */ +export function publishOpeningGuidesForWallEvent(args: { + wall: WallNode + movingId: string + centerS: number + centerY: number + width: number + height: number + includeVertical: boolean + levelYOffset: number + slabElevation: number +}): void { + const { wall, levelYOffset, slabElevation, ...rest } = args + publishOpeningGuides3D({ + ...rest, + wall, + nodes: useScene.getState().nodes, + toWorld: makeWallToWorld(wall, levelYOffset, slabElevation), + }) +} + +/** + * Publish 3D opening guides for an opening being RESIZED via a handle arrow. + * Resolves the host wall + transform from the scene (no wall event), then reuses + * the shared publish. Doors pass `includeVertical: false` (they sit on the + * floor); windows pass `true` so a height drag also shows the live sill/head. + */ +export function publishOpeningResizeGuides( + node: { + id: string + parentId?: string | null + position: readonly [number, number, number] + width: number + height: number + }, + includeVertical: boolean, +): void { + const nodes = useScene.getState().nodes + const wall = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + if (wall?.type !== 'wall') return + publishOpeningGuides3D({ + wall, + movingId: node.id, + centerS: node.position[0], + centerY: node.position[1], + width: node.width, + height: node.height, + includeVertical, + nodes, + toWorld: wallToWorld(wall), + }) +} diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 1e32a160..bca9a2ad 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -6,6 +6,7 @@ import type { WallNode, WindowNode as WindowNodeType, } from '@pascal-app/core' +import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { buildWindowFloorplan } from './floorplan' @@ -50,6 +51,7 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor n.width, + onDrag: (node) => publishOpeningResizeGuides(node, true), apply: (initial, newWidth) => { const rotY = initial.rotation[1] const armX = Math.cos(rotY) @@ -97,6 +99,7 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor n.height, + onDrag: (node) => publishOpeningResizeGuides(node, true), apply: (initial, newHeight) => { // Anchored edge stays in wall-local Y; opposite edge moves. const anchorY = diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 6de64920..6f64a6de 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -31,7 +31,7 @@ import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { clearOpeningGuides3D, - publishOpeningGuides3D, + publishOpeningGuidesForWallEvent, resolveSillSnap, } from '../shared/opening-guides-runtime' import { @@ -304,7 +304,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode target.valid, ) - publishOpeningGuides3D({ + publishOpeningGuidesForWallEvent({ wall: target.wallNode, movingId: movingWindowNode.id, centerS: target.clampedX, @@ -312,15 +312,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode width: movingWindowNode.width, height: movingWindowNode.height, includeVertical: true, - nodes: useScene.getState().nodes, - toWorld: (s, y) => - wallLocalToWorld( - target.wallNode, - s, - y, - getLevelYOffset(), - getSlabElevation(target.event), - ), + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(target.event), }) } diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 26552ebc..2e92c0f6 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -26,6 +26,11 @@ 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 { + clearOpeningGuides3D, + publishOpeningGuidesForWallEvent, + resolveSillSnap, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, @@ -90,6 +95,7 @@ const WindowTool: React.FC = () => { const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } // Alignment candidates — anchors of every alignable object; refreshed @@ -115,18 +121,46 @@ const WindowTool: React.FC = () => { const [x, y, z] = event.localPosition updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const showRoofFallbackCursor = (event: RoofEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const showWallFallbackCursor = (event: WallEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() + } + + // Sill alignment (snap + guide): a sibling sill/centre/top wins over the + // 0.5m grid when within threshold; Shift bypasses both. `movingId` is the + // draft's id once it exists (so it's excluded from the sibling scan), or '' + // before the draft is created (nothing to exclude yet). + const resolvePlacementY = (args: { + event: WallEvent + movingId: string + localX: number + width: number + height: number + }): number => { + const rawY = args.event.localPosition[1] + if (args.event.nativeEvent?.shiftKey === true) return rawY + const sillY = resolveSillSnap({ + wall: args.event.node, + movingId: args.movingId, + localX: args.localX, + localY: rawY, + width: args.width, + height: args.height, + nodes: useScene.getState().nodes, + }) + return sillY ?? snapToHalf(rawY) } const onWallEnter = (event: WallEvent) => { @@ -169,10 +203,7 @@ const WindowTool: React.FC = () => { bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, bypassSnap: event.nativeEvent?.shiftKey === true, }) - const localY = - event.nativeEvent?.shiftKey === true - ? event.localPosition[1] - : snapToHalf(event.localPosition[1]) + const localY = resolvePlacementY({ event, movingId: '', localX, width, height }) const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) @@ -201,6 +232,18 @@ const WindowTool: React.FC = () => { cursorRotation, valid, ) + + publishOpeningGuidesForWallEvent({ + wall: event.node, + movingId: node.id, + centerS: clampedX, + centerY: clampedY, + width, + height, + includeVertical: true, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(event), + }) event.stopPropagation() } @@ -236,10 +279,13 @@ const WindowTool: React.FC = () => { bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, bypassSnap: event.nativeEvent?.shiftKey === true, }) - const localY = - event.nativeEvent?.shiftKey === true - ? event.localPosition[1] - : snapToHalf(event.localPosition[1]) + const localY = resolvePlacementY({ + event, + movingId: draftRef.current?.id ?? '', + localX, + width, + height, + }) const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) @@ -313,6 +359,20 @@ const WindowTool: React.FC = () => { cursorRotation, valid, ) + + if (draftRef.current) { + publishOpeningGuidesForWallEvent({ + wall: event.node, + movingId: draftRef.current.id, + centerS: clampedX, + centerY: clampedY, + width, + height, + includeVertical: true, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(event), + }) + } event.stopPropagation() } @@ -334,10 +394,13 @@ const WindowTool: React.FC = () => { bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, bypassSnap: event.nativeEvent?.shiftKey === true, }) - const localY = - event.nativeEvent?.shiftKey === true - ? event.localPosition[1] - : snapToHalf(event.localPosition[1]) + const localY = resolvePlacementY({ + event, + movingId: draftRef.current.id, + localX, + width: draftRef.current.width, + height: draftRef.current.height, + }) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -404,6 +467,7 @@ const WindowTool: React.FC = () => { triggerSFX('sfx:structure-build') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() + clearOpeningGuides3D() event.stopPropagation() } @@ -467,6 +531,8 @@ const WindowTool: React.FC = () => { useScene.getState().createNode(node, segment.id as AnyNodeId) draftRef.current = node } + // Opening guides are wall-specific; clear them while over a roof face. + clearOpeningGuides3D() updateRoofCursor(target, event.node as RoofNode) event.stopPropagation() } @@ -550,6 +616,7 @@ const WindowTool: React.FC = () => { destroyDraft() hideCursor() useAlignmentGuides.getState().clear() + clearOpeningGuides3D() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove)