From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 01/23] Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 --- .../src/components/tools/item/move-tool.tsx | 6 +- .../components/tools/item/placement-math.ts | 26 ++++ .../tools/item/placement-strategies.ts | 88 ++++++++++++ .../components/tools/item/placement-types.ts | 5 +- .../tools/item/use-placement-coordinator.tsx | 135 +++++++++++++++++- 5 files changed, 251 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5b017ed20..eefaa2a79 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -40,12 +40,12 @@ function getInitialState(node: { }): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } + return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } } if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } + return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } + return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } } function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 49eacf304..112273a41 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,5 @@ import { type AssetInput, isObject } from '@pascal-app/core' +import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import useEditor from '../../../store/use-editor' function getGridSnapStep(): number { @@ -118,3 +119,28 @@ export function stripTransient(meta: any): any { const { isTransient, ...rest } = meta as Record return rest } + +const _up = new Vector3(0, 1, 0) +const _normal = new Vector3() +const _quat = new Quaternion() +const _euler = new Euler() + +/** + * Compute euler rotation that tilts an item so its local +Y aligns with a + * roof surface normal. The normal is in the hit mesh's local space and is + * transformed to world space via the mesh's matrixWorld. + */ +export function calculateRoofRotation( + normal: [number, number, number] | undefined, + objectMatrixWorld: Matrix4, +): [number, number, number] { + if (!normal) return [0, 0, 0] + + _normal.set(normal[0], normal[1], normal[2]) + _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize() + + _quat.setFromUnitVectors(_up, _normal) + _euler.setFromQuaternion(_quat, 'XYZ') + + return [_euler.x, _euler.y, _euler.z] +} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e8724081..5563268b8 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,6 +6,7 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, WallEvent, WallNode, } from '@pascal-app/core' @@ -19,6 +20,7 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, + calculateRoofRotation, getGridAlignedDimensions, getSideFromNormal, isValidWallSideFace, @@ -587,6 +589,87 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// ROOF STRATEGY +// ============================================================================ + +export const roofStrategy = { + enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + if (!ctx.levelId) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + stateUpdate: { surface: 'roof', roofId: event.node.id }, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + parentId: ctx.levelId, + rotation, + }, + cursorRotationY: rotation[1], + cursorRotation: rotation, + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + stopPropagation: true, + } + }, + + move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + cursorRotationY: rotation[1], + cursorRotation: rotation, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + rotation, + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: ctx.draftItem.rotation, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'roof') return null + + return { + stateUpdate: { surface: 'floor', roofId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: [0, ctx.currentCursorRotationY, 0], + }, + cursorRotationY: ctx.currentCursorRotationY, + cursorRotation: [0, ctx.currentCursorRotationY, 0], + gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +686,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Roof: valid if we entered (no spatial validator yet) + if (ctx.state.surface === 'roof') { + return ctx.state.roofId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 538286580..69a3d5ee3 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,7 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + roofId: string | null } // ============================================================================ @@ -58,6 +59,7 @@ export interface PlacementResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] nodeUpdate: Partial | null stopPropagation: boolean dirtyNodeId: AnyNode['id'] | null @@ -72,6 +74,7 @@ export interface TransitionResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] stopPropagation: boolean } 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 fdafe3635..bac2b78fc 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type RoofEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,7 +288,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -484,7 +486,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } const draft = draftNode.current if (draft) { @@ -498,12 +504,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.set(...result.gridPosition) const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } + + const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0] draftNode.create( gridPosition.current, asset, - [0, result.cursorRotationY, 0], + initRotation, configRef.current.defaultScale, ) @@ -1065,6 +1077,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Roof Segment Handlers ---- + + const toRoofLocal = (result: TransitionResult): TransitionResult => { + const local = worldToBuildingLocal(...result.cursorPosition) + const localPos: [number, number, number] = [local.x, local.y, local.z] + return { + ...result, + gridPosition: localPos, + nodeUpdate: { ...result.nodeUpdate, position: localPos }, + } + } + + const onRoofEnter = (event: RoofEvent) => { + const result = roofStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + const local = toRoofLocal(result) + applyTransition(local) + + if (!draftNode.current) { + ensureDraft(local) + } + } + + const onRoofMove = (event: RoofEvent) => { + const ctx = getContext() + + if (ctx.state.surface !== 'roof') { + const enterResult = roofStrategy.enter(ctx, event) + if (!enterResult) return + + event.stopPropagation() + const local = toRoofLocal(enterResult) + applyTransition(local) + if (!draftNode.current) { + ensureDraft(local) + } + return + } + + if (!draftNode.current) { + const enterResult = roofStrategy.enter(getContext(), event) + if (!enterResult) return + event.stopPropagation() + ensureDraft(toRoofLocal(enterResult)) + return + } + + const result = roofStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + const localPos = worldToBuildingLocal(...result.cursorPosition) + gridPosition.current.set(localPos.x, localPos.y, localPos.z) + cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.y = result.cursorRotationY + } + + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + draft.position = [localPos.x, localPos.y, localPos.z] + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + mesh.rotation.set(...result.cursorRotation) + } + } + } + + revalidate() + } + + const onRoofClick = (event: RoofEvent) => { + const result = roofStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + revalidate() + } + } + + const onRoofLeave = (event: RoofEvent) => { + const result = roofStrategy.leave(getContext()) + if (!result) return + + event.stopPropagation() + applyTransition(result) + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1354,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('roof:enter', onRoofEnter) + emitter.on('roof:move', onRoofMove) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) return () => { tearingDown = true @@ -1263,6 +1382,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('roof:enter', onRoofEnter) + emitter.off('roof:move', onRoofMove) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1307,7 +1430,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'roof') { + mesh.position.copy(gridPosition.current) + } else if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 7c1e3839c95c184dadb2b9e761b5da0520598f29 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 20 May 2026 17:21:10 +0530 Subject: [PATCH 02/23] fixed conflict --- .../src/components/tools/item/move-tool.tsx | 69 ---------- .../tools/item/placement-strategies.ts | 84 ------------ .../components/tools/item/placement-types.ts | 8 -- .../tools/item/use-placement-coordinator.tsx | 127 +----------------- 4 files changed, 1 insertion(+), 287 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 2d7f85723..d7c86be96 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -15,76 +15,7 @@ import { MoveBuildingContent } from '../building/move-building-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' -<<<<<<< HEAD -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} -======= import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * MoveTool dispatcher. Routes to (in order): diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index fae9694e9..df67ca169 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,8 @@ import type { GridEvent, ItemEvent, ItemNode, -<<<<<<< HEAD - RoofEvent, -======= ShelfEvent, ShelfNode, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 WallEvent, WallNode, } from '@pascal-app/core' @@ -596,29 +592,6 @@ export const itemSurfaceStrategy = { } // ============================================================================ -<<<<<<< HEAD -// ROOF STRATEGY -// ============================================================================ - -export const roofStrategy = { - enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { - if (ctx.asset.attachTo) return null - if (!ctx.levelId) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - stateUpdate: { surface: 'roof', roofId: event.node.id }, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - parentId: ctx.levelId, - rotation, - }, - cursorRotationY: rotation[1], - cursorRotation: rotation, - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], -======= // SHELF SURFACE STRATEGY // ============================================================================ @@ -703,28 +676,10 @@ export const shelfSurfaceStrategy = { cursorRotationY: ctx.currentCursorRotationY, gridPosition: [x, rowY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, } }, -<<<<<<< HEAD - move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], - cursorRotationY: rotation[1], - cursorRotation: rotation, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - rotation, - }, -======= /** * Handle shelf:move — re-derive the closest row each tick so the user * can slide between rows without leaving the shelf. @@ -753,17 +708,11 @@ export const shelfSurfaceStrategy = { cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: ctx.currentCursorRotationY, nodeUpdate: { position: [x, rowY, z] }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null -======= /** * Handle shelf:click — commit placement on the active row. */ @@ -771,43 +720,17 @@ export const shelfSurfaceStrategy = { if (ctx.state.surface !== 'shelf-surface') return null if (!(ctx.draftItem && ctx.state.shelfId)) return null if (event.node.id !== ctx.state.shelfId) return null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], -<<<<<<< HEAD - parentId: ctx.levelId, - rotation: ctx.draftItem.rotation, -======= parentId: ctx.state.shelfId, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 metadata: stripTransient(ctx.draftItem.metadata), }, stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - - leave(ctx: PlacementContext): TransitionResult | null { - if (ctx.state.surface !== 'roof') return null - - return { - stateUpdate: { surface: 'floor', roofId: null }, - nodeUpdate: { - position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - parentId: ctx.levelId, - rotation: [0, ctx.currentCursorRotationY, 0], - }, - cursorRotationY: ctx.currentCursorRotationY, - cursorRotation: [0, ctx.currentCursorRotationY, 0], - gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - stopPropagation: true, - } - }, -======= } /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed @@ -816,7 +739,6 @@ export const shelfSurfaceStrategy = { * `event.normal` + `event.object`. */ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { return isUpwardItemSurfaceHit(event as unknown as ItemEvent) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ @@ -835,15 +757,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } -<<<<<<< HEAD - // Roof: valid if we entered (no spatial validator yet) - if (ctx.state.surface === 'roof') { - return ctx.state.roofId !== null -======= // Shelf surface: same — size check already happened on enter if (ctx.state.surface === 'shelf-surface') { return ctx.state.shelfId !== null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } const attachTo = ctx.draftItem.asset.attachTo diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 0a593ca75..a3eccc116 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,11 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -<<<<<<< HEAD -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' -======= export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * Tracks which surface the draft item is currently on. @@ -27,9 +23,6 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null -<<<<<<< HEAD - roofId: string | null -======= /** * Active shelf when `surface === 'shelf-surface'`. Items host on the * shelf board closest to the cursor's local Y; the row index isn't @@ -37,7 +30,6 @@ export interface PlacementState { * position via `shelfRowSurfaceYs`. */ shelfId: string | null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ 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 362ddd1dd..b86e426c4 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,11 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, -<<<<<<< HEAD - type RoofEvent, -======= type ShelfEvent, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 sceneRegistry, spatialGridManager, useLiveTransforms, @@ -46,11 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, -<<<<<<< HEAD - roofStrategy, -======= shelfSurfaceStrategy, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -296,9 +288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( -<<<<<<< HEAD - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, -======= config.initialState ?? { surface: 'floor', wallId: null, @@ -306,7 +295,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -1206,58 +1194,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } -<<<<<<< HEAD - // ---- Roof Segment Handlers ---- - - const toRoofLocal = (result: TransitionResult): TransitionResult => { - const local = worldToBuildingLocal(...result.cursorPosition) - const localPos: [number, number, number] = [local.x, local.y, local.z] - return { - ...result, - gridPosition: localPos, - nodeUpdate: { ...result.nodeUpdate, position: localPos }, - } - } - - const onRoofEnter = (event: RoofEvent) => { - const result = roofStrategy.enter(getContext(), event) - if (!result) return - - event.stopPropagation() - const local = toRoofLocal(result) - applyTransition(local) - - if (!draftNode.current) { - ensureDraft(local) - } - } - - const onRoofMove = (event: RoofEvent) => { - const ctx = getContext() - - if (ctx.state.surface !== 'roof') { - const enterResult = roofStrategy.enter(ctx, event) - if (!enterResult) return - - event.stopPropagation() - const local = toRoofLocal(enterResult) - applyTransition(local) - if (!draftNode.current) { - ensureDraft(local) - } - return - } - - if (!draftNode.current) { - const enterResult = roofStrategy.enter(getContext(), event) - if (!enterResult) return - event.stopPropagation() - ensureDraft(toRoofLocal(enterResult)) - return - } - - const result = roofStrategy.move(ctx, event) -======= // ---- Shelf Handlers ---- // // Items can host on shelves the same way they host on tables and @@ -1299,34 +1235,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } const result = shelfSurfaceStrategy.move(ctx, event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() -<<<<<<< HEAD - const localPos = worldToBuildingLocal(...result.cursorPosition) - gridPosition.current.set(localPos.x, localPos.y, localPos.z) - cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - cursorGroupRef.current.rotation.set(...result.cursorRotation) - } else { - cursorGroupRef.current.rotation.y = result.cursorRotationY - } - - const draft = draftNode.current - if (draft && result.nodeUpdate) { - if ('rotation' in result.nodeUpdate) - draft.rotation = result.nodeUpdate.rotation as [number, number, number] - draft.position = [localPos.x, localPos.y, localPos.z] - const mesh = sceneRegistry.nodes.get(draft.id) - if (mesh) { - mesh.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - mesh.rotation.set(...result.cursorRotation) - } - } -======= gridPosition.current.set(...result.gridPosition) const ic = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) @@ -1341,16 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea position: result.cursorPosition, rotation: result.cursorRotationY, }) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } revalidate() } -<<<<<<< HEAD - const onRoofClick = (event: RoofEvent) => { - const result = roofStrategy.click(getContext(), event) -======= const onShelfLeave = (event: ShelfEvent) => { if (placementState.current.surface !== 'shelf-surface') return if (event.node.id !== placementState.current.shelfId) return @@ -1363,7 +1270,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onShelfClick = (event: ShelfEvent) => { const result = shelfSurfaceStrategy.click(getContext(), event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() @@ -1373,20 +1279,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { -<<<<<<< HEAD - revalidate() - } - } - - const onRoofLeave = (event: RoofEvent) => { - const result = roofStrategy.leave(getContext()) - if (!result) return - - event.stopPropagation() - applyTransition(result) - } - -======= const enterResult = shelfSurfaceStrategy.enter(getContext(), event) if (enterResult) { applyTransition(enterResult) @@ -1396,7 +1288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1571,17 +1462,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.on('roof:enter', onRoofEnter) - emitter.on('roof:move', onRoofMove) - emitter.on('roof:click', onRoofClick) - emitter.on('roof:leave', onRoofLeave) -======= emitter.on('shelf:enter', onShelfEnter) emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return () => { tearingDown = true @@ -1606,17 +1490,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.off('roof:enter', onRoofEnter) - emitter.off('roof:move', onRoofMove) - emitter.off('roof:click', onRoofClick) - emitter.off('roof:leave', onRoofLeave) -======= emitter.off('shelf:enter', onShelfEnter) emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1667,9 +1544,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'roof') { - mesh.position.copy(gridPosition.current) - } else if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From cee84d0d70734bcd207ddc9b1d648cf9594b7553 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 18 Jun 2026 01:18:43 +0530 Subject: [PATCH 03/23] feat(duct): ceiling-snap drawing + connected-joint endpoint move Duct draw tool's ceiling mode now hangs each path point just below the ceiling actually covering it (per-room heights tracked), with a translucent surface highlight and a plumb line to the floor so the in-flight point reads clearly from any angle. Dragging a duct corner that sits on a fitting now carries the fitting's other ducts along (port-connectivity second hop), so the joint moves together instead of tearing apart. Co-Authored-By: Claude Opus 4.7 --- apps/editor/components/build-tab.tsx | 28 ++- apps/editor/components/viewer-toolbar.tsx | 3 +- .../components/PreviewToolbar.tsx | 3 +- apps/ifc-converter/next-env.d.ts | 2 +- packages/core/src/schema/nodes/pipe-trap.ts | 2 +- packages/core/src/services/index.ts | 2 + packages/core/src/services/level-height.ts | 44 ++++ .../src/services/port-connectivity.test.ts | 142 ++++++++++++ .../core/src/services/port-connectivity.ts | 77 ++++++- .../editor/src/components/editor/index.tsx | 21 +- .../components/tools/shared/cursor-sphere.tsx | 57 +++-- .../ui/command-palette/editor-commands.tsx | 4 +- .../components/ui/command-palette/index.tsx | 5 +- .../editor/src/components/viewer-overlay.tsx | 51 ++++- packages/nodes/src/duct-segment/tool.tsx | 212 +++++++++++++++--- packages/nodes/src/pipe-trap/definition.ts | 2 +- packages/nodes/src/pipe-trap/geometry.ts | 24 +- packages/nodes/src/pipe-trap/tool.tsx | 2 +- packages/viewer/src/store/use-viewer.ts | 6 +- .../viewer/src/systems/wall/wall-cutout.tsx | 10 +- .../viewer/src/systems/wall/wall-materials.ts | 46 ++++ 21 files changed, 671 insertions(+), 72 deletions(-) create mode 100644 packages/core/src/services/port-connectivity.test.ts diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 0fb0db5c2..ccea9d12f 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -99,7 +99,6 @@ const MEP_ITEMS: MepItem[] = [ { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.png', kind: 'lineset' }, { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.png', kind: 'liquid-line' }, { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.png', kind: 'pipe-segment' }, - { id: 'pipe-trap', label: 'Trap', iconSrc: '/icons/dwv-pipes.png', kind: 'pipe-trap' }, ] /** @@ -168,7 +167,8 @@ export function BuildTab() { const ductContext = mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting') const pipeContext = - mode === 'build' && (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting') + mode === 'build' && + (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting' || activeTool === 'pipe-trap') const liquidLineContext = mode === 'build' && activeTool === 'liquid-line' const isMepItemActive = (item: MepItem) => @@ -421,6 +421,30 @@ export function BuildTab() { /> Add Fitting + ) : null} diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 49638840a..4e99c7d84 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -119,11 +119,12 @@ const levelModeLabels: Record = { solo: 'Solo', } -const wallModeOrder = ['cutaway', 'up', 'down'] as const +const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const const wallModeConfig: Record = { up: { icon: '/icons/room.png', label: 'Full height' }, cutaway: { icon: '/icons/wallcut.png', label: 'Cutaway' }, down: { icon: '/icons/walllow.png', label: 'Low' }, + translucent: { icon: '/icons/wall.png', label: 'Translucent' }, } const SHADING_OPTIONS = [ diff --git a/apps/ifc-converter/components/PreviewToolbar.tsx b/apps/ifc-converter/components/PreviewToolbar.tsx index 192119dd7..6ae43f521 100644 --- a/apps/ifc-converter/components/PreviewToolbar.tsx +++ b/apps/ifc-converter/components/PreviewToolbar.tsx @@ -14,7 +14,7 @@ import { Box, Grid2x2, Layers, Layers2, Maximize, ScanLine, Square } from 'lucid import { type ReactNode, useMemo } from 'react' const levelModes = ['stacked', 'solo', 'exploded', 'manual'] as const -const wallModes = ['up', 'cutaway', 'down'] as const +const wallModes = ['up', 'cutaway', 'down', 'translucent'] as const const levelLabel: Record<(typeof levelModes)[number], string> = { stacked: 'Stack', @@ -27,6 +27,7 @@ const wallLabel: Record<(typeof wallModes)[number], string> = { up: 'Full', cutaway: 'Cutaway', down: 'Down', + translucent: 'Translucent', } function cycle(list: readonly T[], current: T): T { diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/core/src/schema/nodes/pipe-trap.ts b/packages/core/src/schema/nodes/pipe-trap.ts index ca63f242f..0eba603fd 100644 --- a/packages/core/src/schema/nodes/pipe-trap.ts +++ b/packages/core/src/schema/nodes/pipe-trap.ts @@ -21,7 +21,7 @@ export const PipeTrapNode = BaseNode.extend({ // Yaw in radians (the arm direction in plan). rotation: z.number().default(0), // Trap size in inches — matches the fixture drain it serves. - diameter: z.number().min(1.25).max(4).default(1.5), + diameter: z.number().min(1.25).max(4).default(2), pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'), // Developed length of the trap arm (trap weir → vent) in meters. The // draw tool measures it when the arm is drawn; editable in the diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index f52f93944..4d8bac971 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -43,6 +43,8 @@ export { } from './hosting' export { DEFAULT_LEVEL_HEIGHT, + getCeilingAt, + getCeilingHeightAt, getLevelHeight, } from './level-height' export { diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 46f4da0a4..016711ac4 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,3 +1,4 @@ +import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager' import type { CeilingNode, LevelNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' @@ -40,3 +41,46 @@ export function getLevelHeight( return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT } + +/** + * The ceiling covering level-local point `[x, z]`, or `null` when none + * sits over it. Points inside a ceiling's hole are treated as uncovered. + * When ceilings overlap, the lowest one wins — that's the surface a duct + * would actually hang from. + */ +export function getCeilingAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): CeilingNode | null { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return null + + let best: CeilingNode | null = null + for (const childId of level.children) { + const child = nodes[childId as keyof typeof nodes] + if (child?.type !== 'ceiling') continue + const ceiling = child as CeilingNode + if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue + if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue + const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT + if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling + } + return best +} + +/** + * Underside elevation (meters above the level floor) of the ceiling + * covering level-local point `[x, z]`, or `null` when no ceiling sits + * over that point. See {@link getCeilingAt}. + */ +export function getCeilingHeightAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): number | null { + const ceiling = getCeilingAt(levelId, nodes, x, z) + return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null +} diff --git a/packages/core/src/services/port-connectivity.test.ts b/packages/core/src/services/port-connectivity.test.ts new file mode 100644 index 000000000..1cc8e26e8 --- /dev/null +++ b/packages/core/src/services/port-connectivity.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeDefinition, DistributionRole, NodePort } from '../registry' +import { registerNode } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' +import { analyzePortConnectivity, resolveConnectivityUpdates } from './port-connectivity' + +type Point = [number, number, number] + +// Stub registrations mirroring the real kinds' port + role conventions +// without importing the nodes package (which pulls in CSG and can't load +// under the test runner). A run exposes start/end at its path tips; the +// fitting here is a simple two-collar elbow at ±X around its position. +function stubDef( + kind: string, + distributionRole: DistributionRole, + ports: (node: AnyNode) => NodePort[], +): void { + registerNode({ + kind, + schemaVersion: 1, + schema: {}, + category: 'utility', + distributionRole, + defaults: () => ({}), + capabilities: {}, + ports, + } as unknown as AnyNodeDefinition) +} + +stubDef('duct-segment', 'run', (node) => { + const path = (node as unknown as { path: Point[] }).path + const system = (node as unknown as { system: string }).system + return [ + { id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter: 6, system }, + { id: 'end', position: path[path.length - 1]!, direction: [1, 0, 0], diameter: 6, system }, + ] +}) +stubDef('duct-fitting', 'fitting', (node) => { + const position = (node as unknown as { position: Point }).position + const system = (node as unknown as { system: string }).system + return [ + { + id: 'inlet', + position: [position[0] - 0.2, position[1], position[2]], + direction: [-1, 0, 0], + diameter: 6, + system, + }, + { + id: 'outlet', + position: [position[0] + 0.2, position[1], position[2]], + direction: [1, 0, 0], + diameter: 6, + system, + }, + ] +}) + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} + +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +describe('port connectivity — fitting joint second hop', () => { + // Layout: duct A ends at the fitting's inlet (−0.2,0,0); duct B starts at the + // fitting's outlet (+0.2,0,0). Dragging A's far end toward/through the + // fitting carries the fitting AND duct B's near endpoint along. + function joint() { + const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' }) + const ductA = makeNode('duct-segment', { + path: [ + [-3, 0, 0], + [-0.2, 0, 0], + ], + system: 'supply', + }) + const ductB = makeNode('duct-segment', { + path: [ + [0.2, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + return { fitting, ductA, ductB } + } + + test('dragging duct A carries the fitting (rigid) and duct B (sibling endpoint)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + + const connectivity = analyzePortConnectivity(ductA, nodes) + // The fitting follows rigidly… + expect( + connectivity.connections.find((c) => c.kind === 'rigid-node' && c.nodeId === fitting.id), + ).toBeDefined() + // …and duct B is picked up as a second-hop sibling endpoint. + expect( + connectivity.connections.find( + (c) => c.kind === 'duct-endpoint-follow' && c.nodeId === ductB.id, + ), + ).toBeDefined() + + // Move duct A's mated endpoint (the 'end' port, path index 1) by +1 in Z. + const moved = { + ...(ductA as Record), + path: [ + [-3, 0, 0], + [-0.2, 0, 1], + ], + } as AnyNode + const updates = resolveConnectivityUpdates(connectivity, moved) + + const fittingUpdate = updates.find((u) => u.id === fitting.id) + expect((fittingUpdate!.data as { position: Point }).position).toEqual([0, 0, 1]) + + const bUpdate = updates.find((u) => u.id === ductB.id) + const bPath = (bUpdate!.data as { path: Point[] }).path + // Duct B's near end (index 0, on the outlet collar) rode +1 in Z… + expect(bPath[0]).toEqual([0.2, 0, 1]) + // …its far end stayed put (the run stretches). + expect(bPath[1]).toEqual([3, 0, 0]) + }) + + test('an unrelated run not on the fitting is left alone', () => { + const { fitting, ductA, ductB } = joint() + const distant = makeNode('duct-segment', { + path: [ + [10, 0, 0], + [13, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(fitting, ductA, ductB, distant) + const connectivity = analyzePortConnectivity(ductA, nodes) + expect(connectivity.connections.find((c) => c.nodeId === distant.id)).toBeUndefined() + }) +}) diff --git a/packages/core/src/services/port-connectivity.ts b/packages/core/src/services/port-connectivity.ts index 3dbbbf352..b7907e586 100644 --- a/packages/core/src/services/port-connectivity.ts +++ b/packages/core/src/services/port-connectivity.ts @@ -16,11 +16,15 @@ import type { AnyNode, AnyNodeId } from '../schema' * core and is consumed by the editor's move tool and the duct-segment * system alike. * - * Propagation is intentionally **one hop**: a moved fitting stretches the - * ducts touching it (their near endpoint follows) and rigidly drags any - * fitting mated collar-to-collar, but it does NOT chase the far end of - * those ducts or anything beyond. Bounded and predictable — no runaway - * network rearrangement. + * Propagation is intentionally bounded. A moved node stretches the ducts + * touching it (their near endpoint follows) and rigidly drags any fitting + * mated collar-to-collar. It also carries the *sibling* ducts on that + * dragged-along fitting — the other runs sharing the fitting's collars — + * so dragging one duct's corner moves the whole joint together instead of + * tearing the fitting away from its other legs. Their near endpoints + * translate with the fitting; their far ends stay put (they stretch). It + * stops there: it does NOT chase those far ends or hop onward through the + * next fitting. Bounded and predictable — no runaway network rearrangement. */ type Point = readonly [number, number, number] @@ -54,6 +58,22 @@ export type PortConnection = /** Partner node's `position` at edit-start. */ startPosition: Point } + | { + /** A sibling duct hanging off one of the dragged-along fitting's OTHER + * collars (second hop). The fitting follows the moved port rigidly, so + * this run's near endpoint translates by that same delta to stay welded + * to its collar — the whole joint moves together. The far endpoint + * stays put (the run stretches). */ + kind: 'duct-endpoint-follow' + nodeId: AnyNodeId + /** Index in the run's `path` that rides the fitting. */ + pathIndex: number + /** The moved node's port id whose delta drives the fitting (and so this + * run's endpoint). */ + movedPortId: string + /** The run's full path at edit-start (other points are preserved). */ + startPath: Point[] + } export type PortConnectivity = { movedNodeId: AnyNodeId @@ -161,6 +181,43 @@ export function analyzePortConnectivity( } } + // Second hop: each fitting we drag rigidly carries its OTHER runs along. + // Find every run endpoint sitting on one of that fitting's collars (apart + // from the run we're already moving) and drive it by the same port delta, + // so the whole joint translates together instead of the fitting peeling + // off its other legs. + const rigidFittings = connections.filter( + (c): c is Extract => c.kind === 'rigid-node', + ) + const alreadyTracked = new Set(connections.map((c) => c.nodeId)) + alreadyTracked.add(movedNode.id as AnyNodeId) + for (const fittingConn of rigidFittings) { + const fitting = nodes[fittingConn.nodeId] + if (!fitting) continue + const fittingPorts = portsOf(fitting) ?? [] + for (const fp of fittingPorts) { + for (const other of Object.values(nodes)) { + if (!other || alreadyTracked.has(other.id as AnyNodeId)) continue + if (roleOf(other) !== 'run') continue + const path = (other as unknown as { path?: Point[] }).path + if (!Array.isArray(path) || path.length < 2) continue + const otherPorts = portsOf(other) + if (!otherPorts) continue + const ep = otherPorts.find((p) => distSq(p.position, fp.position) <= epsSq) + if (!ep) continue + if (ep.system && fp.system && ep.system !== fp.system) continue + connections.push({ + kind: 'duct-endpoint-follow', + nodeId: other.id, + pathIndex: ep.id === 'start' ? 0 : path.length - 1, + movedPortId: fittingConn.movedPortId, + startPath: path.map((p) => [...p] as Point), + }) + alreadyTracked.add(other.id as AnyNodeId) + } + } + } + return { movedNodeId: movedNode.id as AnyNodeId, connections, startMovedPorts } } @@ -193,6 +250,16 @@ export function resolveConnectivityUpdates( i === conn.pathIndex ? ([now[0], now[1], now[2]] as Point) : ([...p] as Point), ) updates.push({ id: conn.nodeId, data: { path } as Partial }) + } else if (conn.kind === 'duct-endpoint-follow') { + // Sibling run on a dragged-along fitting: translate its near endpoint by + // the same port delta the fitting follows. Far end stays put (stretch). + const dx = now[0] - start[0] + const dy = now[1] - start[1] + const dz = now[2] - start[2] + const path = conn.startPath.map((p, i) => + i === conn.pathIndex ? ([p[0] + dx, p[1] + dy, p[2] + dz] as Point) : ([...p] as Point), + ) + updates.push({ id: conn.nodeId, data: { path } as Partial }) } else { const dx = now[0] - start[0] const dy = now[1] - start[1] diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index ca6dde067..3806885e9 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -1136,6 +1136,7 @@ export default function Editor({ + {isFirstPersonMode && } @@ -1194,8 +1195,14 @@ export default function Editor({ {!isLoading && isPreviewMode ? (
- useEditor.getState().setPreviewMode(false)} /> -
{previewViewerContent}
+ {isFirstPersonMode ? ( + useEditor.getState().setFirstPersonMode(false)} /> + ) : ( + useEditor.getState().setPreviewMode(false)} /> + )} +
+ {previewViewerContent} +
) : ( <> @@ -1259,8 +1266,14 @@ export default function Editor({ {!isLoading && isPreviewMode ? ( <> - useEditor.getState().setPreviewMode(false)} /> -
{previewViewerContent}
+ {isFirstPersonMode ? ( + useEditor.getState().setFirstPersonMode(false)} /> + ) : ( + useEditor.getState().setPreviewMode(false)} /> + )} +
+ {previewViewerContent} +
) : ( <> diff --git a/packages/editor/src/components/tools/shared/cursor-sphere.tsx b/packages/editor/src/components/tools/shared/cursor-sphere.tsx index 10af0c084..d403842aa 100644 --- a/packages/editor/src/components/tools/shared/cursor-sphere.tsx +++ b/packages/editor/src/components/tools/shared/cursor-sphere.tsx @@ -12,12 +12,28 @@ interface CursorSphereProps extends Omit { depthWrite?: boolean showTooltip?: boolean height?: number + /** + * Put the bright marker dot at the TIP of the vertical line (y = height) + * instead of on the ground ring. Used when the point being placed hangs + * above the floor (e.g. duct drawn against the ceiling): the dot rides at + * the cursor / placement point while the line drops to a floor ring that + * keeps the plan position readable. + */ + dotAtTip?: boolean /** Custom tooltip content — overrides the auto-detected build tool icon */ tooltipContent?: React.ReactNode } export const CursorSphere = forwardRef(function CursorSphere( - { color = '#818cf8', showTooltip = true, height = 2.5, visible = true, tooltipContent, ...props }, + { + color = '#818cf8', + showTooltip = true, + height = 2.5, + dotAtTip = false, + visible = true, + tooltipContent, + ...props + }, ref, ) { const tool = useEditor((s) => s.tool) @@ -39,19 +55,23 @@ export const CursorSphere = forwardRef(function Cursor return ( - {/* Flat marker on the ground */} + {/* Flat marker on the ground. The bright center dot moves to the tip + of the line in `dotAtTip` mode (the placement point hangs above the + floor), leaving a faint ring here so the plan position stays read. */} - {/* Center dot */} - - - - + {/* Center dot — at the ground unless the placement point is elevated */} + {!dotAtTip && ( + + + + + )} {/* Outer ring / glow */} @@ -60,7 +80,7 @@ export const CursorSphere = forwardRef(function Cursor color={color} depthTest={false} depthWrite={false} - opacity={0.25} + opacity={dotAtTip ? 0.2 : 0.25} transparent /> @@ -80,6 +100,15 @@ export const CursorSphere = forwardRef(function Cursor )} + {/* Bright marker dot at the tip of the line — the actual placement + point, riding at the cursor while the line drops to the floor. */} + {dotAtTip && height > 0 && ( + + + + + )} + {/* Tool Icon Tooltip at the top of the line */} {isVisible && showTooltip && (activeToolConfig || tooltipContent) && ( , - keywords: ['wall', 'cutaway', 'up', 'down', 'view'], + keywords: ['wall', 'cutaway', 'up', 'down', 'translucent', 'view'], badge: () => { const mode = useViewer.getState().wallMode - return { cutaway: 'Cutaway', up: 'Up', down: 'Down' }[mode] + return { cutaway: 'Cutaway', up: 'Up', down: 'Down', translucent: 'Translucent' }[mode] }, navigate: true, execute: () => navigateTo('wall-mode'), diff --git a/packages/editor/src/components/ui/command-palette/index.tsx b/packages/editor/src/components/ui/command-palette/index.tsx index 4f9d2964e..2979d80f4 100644 --- a/packages/editor/src/components/ui/command-palette/index.tsx +++ b/packages/editor/src/components/ui/command-palette/index.tsx @@ -244,10 +244,11 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm setOpen(false) } - const wallModeLabel: Record<'cutaway' | 'up' | 'down', string> = { + const wallModeLabel: Record<'cutaway' | 'up' | 'down' | 'translucent', string> = { cutaway: 'Cutaway', up: 'Up', down: 'Down', + translucent: 'Translucent', } const levelModeLabel: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = { manual: 'Manual', @@ -373,7 +374,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm {/* ── Wall Mode sub-page ────────────────────────────────────── */} {page === 'wall-mode' && ( - {(['cutaway', 'up', 'down'] as const).map((mode) => ( + {(['cutaway', 'up', 'down', 'translucent'] as const).map((mode) => ( ('[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 + } +} + const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { stacked: 'Stacked', exploded: 'Exploded', @@ -80,6 +101,12 @@ const wallModeConfig = { ), label: 'Low', }, + translucent: { + icon: (props: any) => ( + Translucent + ), + label: 'Translucent', + }, } const SHADING_OPTIONS = [ @@ -554,7 +581,12 @@ export const ViewerOverlay = ({ } label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`} onClick={() => { - const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] + const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [ + 'cutaway', + 'up', + 'down', + 'translucent', + ] const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') }} @@ -615,6 +647,23 @@ export const ViewerOverlay = ({ src="/icons/topview.png" /> + +
+ + {/* First-person walkthrough */} + { + flushSync(() => useEditor.getState().setFirstPersonMode(true)) + requestWalkthroughPointerLock() + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + +
diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index eb8ca57d2..194421490 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -2,11 +2,12 @@ import { type AnyNode, + type CeilingNode, DuctSegmentNode, emitter, type GridEvent, - getLevelHeight, - sceneRegistry, + getCeilingAt, + getCeilingHeightAt, useScene, } from '@pascal-app/core' import { @@ -19,8 +20,17 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { useEffect, useRef, useState } from 'react' -import { type Group, Matrix4, Vector3 } from 'three' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + type BufferGeometry, + DoubleSide, + type Group, + Matrix4, + Path, + Shape, + ShapeGeometry, + Vector3, +} from 'three' import { getDuctFittingPorts } from '../duct-fitting/ports' import { planCrossAtRunBody, @@ -69,9 +79,10 @@ import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry' * vertical mouse motion drives Y. Click commits the riser segment. * - **[ / ]** step the duct diameter through nominal US sizes; the * ghost preview and the committed node both use it. - * - **C** toggles ceiling-level placement: the start point lands at - * the level's ceiling height (duct top hugging the ceiling) instead - * of the floor. Subsequent points inherit the start's Y as usual. + * - **C** toggles ceiling-level placement: each point lands just below + * the ceiling actually covering it (duct top hugging that ceiling) + * instead of the floor, so a run tracks per-room ceiling heights. + * Points not under any ceiling fall back to the floor. * - Esc clears an anchored start point. */ const PREVIEW_OPACITY = 0.55 @@ -288,6 +299,10 @@ const DuctSegmentTool = () => { // When the cursor is within snap range of an existing duct's endpoint we // surface a brighter indicator and commit at the endpoint's exact coords. const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null) + // In ceiling mode, the ceiling the cursor is currently under — rendered as + // a translucent overlay so the duct reads as hung against a real surface + // rather than a dot floating in space. Null when off-ceiling. + const [hoverCeiling, setHoverCeiling] = useState(null) // True while Alt is held with a last point on the draft — drives the // vertical-cylinder ghost and the cursor HUD label. const [altActive, setAltActive] = useState(false) @@ -547,16 +562,17 @@ const DuctSegmentTool = () => { setAltActive(false) } - // Base Y for a fresh run's first point: floor (0) by default, or just - // below the level's ceiling in ceiling mode so the duct's top hugs the - // ceiling (centerline = ceiling height − radius). - const resolveBaseY = (): number => { + // Y for a point at level-local `[x, z]`. Floor (0) when ceiling mode is + // off. In ceiling mode, query the ceiling actually covering that point + // and hang the duct just below it (centerline = ceiling underside − + // half the duct's vertical dimension) so its top hugs the ceiling. Each + // point follows its own ceiling, so a run stepping into a room with a + // different ceiling height tracks that change. Points not under any + // ceiling fall back to the floor. + const resolveCeilingY = (x: number, z: number): number => { if (!ceilingModeRef.current) return 0 - const ceiling = getLevelHeight( - activeLevelId, - useScene.getState().nodes, - (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, - ) + const ceiling = getCeilingHeightAt(activeLevelId, useScene.getState().nodes, x, z) + if (ceiling === null) return 0 const p = profileRef.current const verticalIn = p.shape === 'round' ? p.diameter : p.height return Math.max(0, ceiling - (verticalIn * 0.0254) / 2) @@ -571,11 +587,11 @@ const DuctSegmentTool = () => { body: RunBodyHit | null } => { const last = draftRef.current.at(-1) - // First point of the run: grid-snapped placement at the base Y (floor, - // or ceiling height in ceiling mode). Endpoint snap can still join an - // existing run. + // First point of the run: grid-snapped placement. Y follows the + // ceiling under the cursor in ceiling mode (floor otherwise). + // Endpoint snap can still join an existing run. if (!last) { - const baseY = resolveBaseY() + const baseY = resolveCeilingY(event.localPosition[0], event.localPosition[2]) const raw: [number, number, number] = [ event.localPosition[0], baseY, @@ -601,15 +617,20 @@ const DuctSegmentTool = () => { const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M) if (body) return { point: body.point, snapped: body.point, port: null, body } } + const sx = snap(raw[0], step) + const sz = snap(raw[2], step) return { - point: [snap(raw[0], step), baseY, snap(raw[2], step)], + point: [sx, resolveCeilingY(sx, sz), sz], snapped: null, port: null, body: null, } } // Subsequent points: angle-locked to 45° from `last` (Shift releases). - // Y stays at `last[1]` — depth changes come from Shift+click risers. + // Y inherits `last[1]` for the angle/probe math; the free placement + // below re-resolves it from the ceiling under the point in ceiling + // mode, so a run stepping into a room with a different ceiling height + // tracks that change. Depth changes otherwise come from Alt risers. const rawXZ: [number, number, number] = [ event.localPosition[0], last[1], @@ -638,8 +659,11 @@ const DuctSegmentTool = () => { const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M) if (body) return { point: body.point, snapped: body.point, port: null, body } } + const fx = snap(angled[0], step) + const fz = snap(angled[2], step) + const fy = ceilingModeRef.current ? resolveCeilingY(fx, fz) : angled[1] return { - point: [snap(angled[0], step), angled[1], snap(angled[2], step)], + point: [fx, fy, fz], snapped: null, port: null, body: null, @@ -681,6 +705,17 @@ const DuctSegmentTool = () => { return { ...r, point } } + // The ceiling the cursor is under (ceiling mode only) — drives the + // translucent surface overlay so the in-flight point reads as hung + // against a real ceiling. Cleared when off-ceiling or out of mode. + const updateHoverCeiling = (x: number, z: number) => { + if (!ceilingModeRef.current) { + setHoverCeiling(null) + return + } + setHoverCeiling(getCeilingAt(activeLevelId, useScene.getState().nodes, x, z)) + } + const onMove = (event: GridEvent) => { const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY if (typeof clientY === 'number') lastClientYRef.current = clientY @@ -691,12 +726,14 @@ const DuctSegmentTool = () => { clearDrawAlignment() setCursorPos(point) setSnapTarget(null) + updateHoverCeiling(point[0], point[2]) return } } const { point, snapped } = resolveAlignedPoint(event) setCursorPos(point) setSnapTarget(snapped) + updateHoverCeiling(point[0], point[2]) } const onClick = (event: GridEvent) => { @@ -783,12 +820,14 @@ const DuctSegmentTool = () => { setProfile((p) => ({ ...p, shape: p.shape === 'round' ? 'rect' : 'round' })) triggerSFX('sfx:grid-snap') } else if (e.key === 'c' || e.key === 'C') { - // Toggle ceiling mode. Only the first point reads the base Y, so - // toggling mid-run is a no-op until the next fresh segment — flip - // it only while unanchored to keep the behaviour predictable. + // Toggle ceiling mode: points hang from the ceiling above them + // (duct top hugging the ceiling) instead of sitting on the floor. + // Only flip while unanchored — already-placed points keep their Y, + // so a mid-run toggle would split a run across two height regimes. if (draftRef.current.length > 0) return e.preventDefault() setCeilingMode((m) => !m) + setHoverCeiling(null) triggerSFX('sfx:grid-snap') } } @@ -807,6 +846,7 @@ const DuctSegmentTool = () => { setDraftPoints([]) setCursorPos(null) setSnapTarget(null) + setHoverCeiling(null) startPortRef.current = null startBodyRef.current = null } @@ -868,30 +908,60 @@ const DuctSegmentTool = () => { : 'z' : undefined + // When the in-flight point hangs above the floor (ceiling mode, or an + // Alt riser), the cursor marker itself rides AT the point (where the + // mouse is aiming and the next click commits), and a plumb line drops + // straight down to a faint ground ring on the floor below — so the plan + // position stays legible from any angle. A floor-level point keeps the + // standard fixed-height cursor look. + const cursorElevation = cursorPos ? cursorPos[1] : 0 + const isElevated = cursorElevation > 0.001 + const cursorGround: [number, number, number] | null = cursorPos + ? [cursorPos[0], 0, cursorPos[2]] + : null + return ( + {/* Ceiling-mode surface highlight — the ceiling the cursor is under, + tinted at its own elevation so the duct reads as hung against a + real surface instead of a point floating in space. */} + {ceilingMode && hoverCeiling && } {/* Cursor marker — the same ground ring + vertical line + tool-icon badge walls and items show while drawing (icon resolved from the active `duct-segment` structure-tools entry). The dimension pill rides just above the cursor. */} - {cursorPos && ( + {cursorPos && cursorGround && ( <> - + {/* In ceiling mode (or any elevated point) the ground ring sits on + the floor below the cursor and the line rises to the placement + point, with the bright dot + tool badge at its tip — exactly + where the next click commits. At floor level it's the standard + fixed-height cursor. */} + {isElevated ? ( + + ) : ( + + )} {pillParts && ( -
- +
{ceilingMode && !last && (
Ceiling · C to toggle
)} +
@@ -928,6 +998,86 @@ const DuctSegmentTool = () => { ) } +/** + * Build a horizontal `ShapeGeometry` for a ceiling polygon (with holes) in + * level-local XZ, laid flat in the XZ plane. Mirrors the ceiling renderer / + * move-tool convention (Z negated, then rotated onto the floor plane). + */ +function buildCeilingShape( + polygon: Array<[number, number]>, + holes: Array>, +): BufferGeometry | null { + if (polygon.length < 3) return null + const shape = new Shape() + const first = polygon[0]! + shape.moveTo(first[0], -first[1]) + for (let i = 1; i < polygon.length; i++) { + const pt = polygon[i]! + shape.lineTo(pt[0], -pt[1]) + } + shape.closePath() + for (const holePolygon of holes) { + if (holePolygon.length < 3) continue + const hole = new Path() + const hf = holePolygon[0]! + hole.moveTo(hf[0], -hf[1]) + for (let i = 1; i < holePolygon.length; i++) { + const pt = holePolygon[i]! + hole.lineTo(pt[0], -pt[1]) + } + hole.closePath() + shape.holes.push(hole) + } + const geometry = new ShapeGeometry(shape) + geometry.rotateX(-Math.PI / 2) + return geometry +} + +/** + * Translucent overlay of the ceiling the cursor is under, drawn at the + * ceiling's own height. Gives the in-flight duct point a real surface to + * read against, so "hung against the ceiling" is visible from any angle + * instead of being a dot floating in space. + */ +function CeilingHighlight({ ceiling }: { ceiling: CeilingNode }) { + const geometry = useMemo( + () => buildCeilingShape(ceiling.polygon, ceiling.holes), + [ceiling.polygon, ceiling.holes], + ) + const outline = useMemo(() => { + if (ceiling.polygon.length < 2) return null + const pts = ceiling.polygon.map(([x, z]) => new Vector3(x, 0, z)) + const f = ceiling.polygon[0]! + pts.push(new Vector3(f[0], 0, f[1])) + return pts + }, [ceiling.polygon]) + if (!geometry) return null + const y = ceiling.height ?? 2.5 + return ( + + + + + {outline && ( + + { + if (g) g.setFromPoints(outline) + }} + /> + + + )} + + ) +} + function PreviewSegment({ a, b, diff --git a/packages/nodes/src/pipe-trap/definition.ts b/packages/nodes/src/pipe-trap/definition.ts index 541afb061..bfacc1817 100644 --- a/packages/nodes/src/pipe-trap/definition.ts +++ b/packages/nodes/src/pipe-trap/definition.ts @@ -26,7 +26,7 @@ export const pipeTrapDefinition: NodeDefinition = { metadata: {}, position: [0, 0, 0], rotation: 0, - diameter: 1.5, + diameter: 2, pipeMaterial: 'pvc', armLengthM: 0, }), diff --git a/packages/nodes/src/pipe-trap/geometry.ts b/packages/nodes/src/pipe-trap/geometry.ts index 56f788210..d0f85a8ae 100644 --- a/packages/nodes/src/pipe-trap/geometry.ts +++ b/packages/nodes/src/pipe-trap/geometry.ts @@ -1,9 +1,14 @@ -import { Group, Mesh, TorusGeometry, Vector3 } from 'three' +import { DoubleSide, Group, Mesh, SphereGeometry, TorusGeometry, Vector3 } from 'three' import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry' import { createPipeMaterial } from '../pipe-segment/geometry' import type { PipeTrapNode } from './schema' const BEND_SEGMENTS = 24 +const RADIAL_SEGMENTS = 20 +/** Sphere hubs filling the U-bend → stub joints read as a coupling and, + * more importantly, hide the wedge gap left where the horizontal arm's + * flat end cap meets the bend's upward-facing opening at 90°. */ +const HUB_RADIUS_FACTOR = 1.12 /** Inlet drop and arm reach in pipe radii — keeps the trap proportional * to its size without per-size tuning. */ @@ -19,8 +24,12 @@ const ARM_REACH_RADII = 3.2 export function buildPipeTrapGeometry(node: PipeTrapNode): Group { const group = new Group() const material = createPipeMaterial({ pipeMaterial: node.pipeMaterial, system: 'waste' }) + // Double-sided so the thin pipe walls don't drop out at grazing angles, + // which read as cuts/holes on the bend and stub ends. + material.side = DoubleSide const radius = (node.diameter * INCHES_TO_METERS) / 2 const bendR = radius * 1.6 + const hubRadius = radius * HUB_RADIUS_FACTOR // U-bend: half torus in the XY plane, opening upward. Sits so its two // tops are at y = bendR (the inlet riser and the arm rise). @@ -49,6 +58,19 @@ export function buildPipeTrapGeometry(node: PipeTrapNode): Group { const arm = buildSection(armStart, armEnd, radius, material, 'pipe-trap-arm') if (arm) group.add(arm) + // Coupling hubs at the two U-bend tops where the straight stubs meet the + // torus. They fill the 90° miter wedge (the visible "cut") and read as + // the trap's slip-joint nuts. + for (const [i, center] of [ + new Vector3(0, bendR, 0), + new Vector3(bendR * 2, bendR, 0), + ].entries()) { + const hub = new Mesh(new SphereGeometry(hubRadius, RADIAL_SEGMENTS, 12), material) + hub.name = `pipe-trap-hub-${i}` + hub.position.copy(center) + group.add(hub) + } + return group } diff --git a/packages/nodes/src/pipe-trap/tool.tsx b/packages/nodes/src/pipe-trap/tool.tsx index 5de8795f0..ad447f498 100644 --- a/packages/nodes/src/pipe-trap/tool.tsx +++ b/packages/nodes/src/pipe-trap/tool.tsx @@ -26,7 +26,7 @@ const PipeTrapTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) const [cursor, setCursor] = useState<[number, number, number] | null>(null) const [yaw, setYaw] = useState(0) - const [diameter] = useState(1.5) + const [diameter] = useState(pipeTrapDefinition.defaults().diameter) const yawRef = useRef(0) const diameterRef = useRef(diameter) diameterRef.current = diameter diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 11cc67e3d..949f48472 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -63,8 +63,8 @@ type ViewerState = { levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void - wallMode: 'up' | 'cutaway' | 'down' - setWallMode: (mode: 'up' | 'cutaway' | 'down') => void + wallMode: 'up' | 'cutaway' | 'down' | 'translucent' + setWallMode: (mode: 'up' | 'cutaway' | 'down' | 'translucent') => void showScans: boolean setShowScans: (show: boolean) => void @@ -138,7 +138,7 @@ 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 +const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'] 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 diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 89cc6bfaf..7c2ecbb05 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -106,7 +106,13 @@ export const WallCutout = () => { const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId) const materials = getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme) - if (hideWall) { + if (wallMode === 'translucent') { + ;(wallMesh as Mesh).material = isDeleteHighlighted + ? materials.deleteTranslucent + : isSelectionHighlighted + ? materials.highlightedTranslucent + : materials.translucent + } else if (hideWall) { ;(wallMesh as Mesh).material = isDeleteHighlighted ? materials.deleteInvisible : isSelectionHighlighted @@ -152,6 +158,8 @@ export const WallCutout = () => { wallMesh.material = mats.visible } else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) { wallMesh.material = mats.invisible + } else if (current === mats.highlightedTranslucent || current === mats.deleteTranslucent) { + wallMesh.material = mats.translucent } }) } diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 3c807dc8b..a99f8cb0a 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -43,10 +43,13 @@ export type WallMaterialArray = [Material, Material, Material] export interface WallMaterials { visible: WallMaterialArray invisible: WallMaterialArray + translucent: WallMaterialArray deleteVisible: WallMaterialArray deleteInvisible: WallMaterialArray + deleteTranslucent: WallMaterialArray highlightedVisible: WallMaterialArray highlightedInvisible: WallMaterialArray + highlightedTranslucent: WallMaterialArray materialHash: string } @@ -154,6 +157,25 @@ function createInvisibleWallMaterial(color: string, shading: RenderShading): Mat return material } +function createTranslucentWallMaterial(color: string, shading: RenderShading): Material { + const material = + shading === 'solid' + ? new MeshLambertNodeMaterial({ + transparent: true, + color, + opacity: 0.35, + depthWrite: false, + }) + : new MeshStandardNodeMaterial({ + transparent: true, + color, + opacity: 0.35, + depthWrite: false, + }) + + return material +} + function mapWallMaterialArray( materials: WallMaterialArray, iteratee: (material: Material, index: number) => Material, @@ -205,10 +227,13 @@ export function getMaterialsForWall( if (existing) { disposeOwnedMaterials([ existing.invisible, + existing.translucent, existing.deleteVisible, existing.deleteInvisible, + existing.deleteTranslucent, existing.highlightedVisible, existing.highlightedInvisible, + existing.highlightedTranslucent, ]) } @@ -243,26 +268,47 @@ export function getMaterialsForWall( ), ] + const translucent: WallMaterialArray = [ + createTranslucentWallMaterial(wallRoleColor, textures ? shading : 'solid'), + createTranslucentWallMaterial( + textures ? getSurfaceColor(interiorSpec, wallRoleColor) : wallRoleColor, + textures ? shading : 'solid', + ), + createTranslucentWallMaterial( + textures ? getSurfaceColor(exteriorSpec, wallRoleColor) : wallRoleColor, + textures ? shading : 'solid', + ), + ] + const highlightedVisible = mapWallMaterialArray(visible, (material) => createHighlightedWallMaterial(material, 'selection'), ) const highlightedInvisible = mapWallMaterialArray(invisible, (material) => createHighlightedWallMaterial(material, 'selection'), ) + const highlightedTranslucent = mapWallMaterialArray(translucent, (material) => + createHighlightedWallMaterial(material, 'selection'), + ) const deleteVisible = mapWallMaterialArray(visible, (material) => createHighlightedWallMaterial(material, 'delete'), ) const deleteInvisible = mapWallMaterialArray(invisible, (material) => createHighlightedWallMaterial(material, 'delete'), ) + const deleteTranslucent = mapWallMaterialArray(translucent, (material) => + createHighlightedWallMaterial(material, 'delete'), + ) const result: WallMaterials = { visible, invisible, + translucent, deleteVisible, deleteInvisible, + deleteTranslucent, highlightedVisible, highlightedInvisible, + highlightedTranslucent, materialHash, } From b8e2d5f7575083f1bb724dc8584f31a5e33fc9b6 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 18 Jun 2026 14:46:55 +0530 Subject: [PATCH 04/23] feat(mep): detach + vertical modifiers for duct/pipe joint editing Alt detaches a dragged duct/pipe endpoint or fitting from its connected joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical riser movement on the fitting move. Behavioral parity across 2D and 3D. Co-Authored-By: Claude Opus 4.7 --- apps/ifc-converter/next-env.d.ts | 2 +- .../src/services/port-connectivity.test.ts | 94 ++-- .../core/src/services/port-connectivity.ts | 422 ++++++++++-------- .../editor/handles/handle-arrow.tsx | 2 +- .../editor/wall-move-side-handles.tsx | 2 +- packages/nodes/src/duct-fitting/move-tool.tsx | 106 ++++- packages/nodes/src/duct-segment/move-tool.tsx | 26 +- packages/nodes/src/duct-segment/selection.tsx | 361 ++++++++++----- packages/nodes/src/duct-segment/tool.tsx | 26 +- packages/nodes/src/lineset/move-tool.tsx | 26 +- packages/nodes/src/pipe-segment/move-tool.tsx | 26 +- packages/nodes/src/pipe-segment/selection.tsx | 143 ++++-- packages/nodes/src/pipe-segment/tool.tsx | 18 +- .../nodes/src/shared/elbow-endpoint-reaim.ts | 99 ++++ .../nodes/src/shared/path-point-affordance.ts | 95 +++- .../src/shared/port-connectivity-pipe.test.ts | 15 +- .../nodes/src/shared/run-move-connectivity.ts | 99 ++++ 17 files changed, 1143 insertions(+), 419 deletions(-) create mode 100644 packages/nodes/src/shared/elbow-endpoint-reaim.ts create mode 100644 packages/nodes/src/shared/run-move-connectivity.ts diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/core/src/services/port-connectivity.test.ts b/packages/core/src/services/port-connectivity.test.ts index 1cc8e26e8..f2af8057f 100644 --- a/packages/core/src/services/port-connectivity.test.ts +++ b/packages/core/src/services/port-connectivity.test.ts @@ -66,10 +66,11 @@ function sceneOf(...nodes: AnyNode[]): Record { return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record } -describe('port connectivity — fitting joint second hop', () => { +describe('port connectivity — joint follow (stretch vs translate)', () => { // Layout: duct A ends at the fitting's inlet (−0.2,0,0); duct B starts at the - // fitting's outlet (+0.2,0,0). Dragging A's far end toward/through the - // fitting carries the fitting AND duct B's near endpoint along. + // fitting's outlet (+0.2,0,0). Both runs lie on the X axis. Dragging A's + // mated endpoint carries the fitting and duct B; how B reacts depends on + // whether the drag is along its axis (stretch) or across it (translate). function joint() { const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' }) const ductA = makeNode('duct-segment', { @@ -89,43 +90,82 @@ describe('port connectivity — fitting joint second hop', () => { return { fitting, ductA, ductB } } - test('dragging duct A carries the fitting (rigid) and duct B (sibling endpoint)', () => { - const { fitting, ductA, ductB } = joint() - const nodes = sceneOf(fitting, ductA, ductB) + function movedA(end: Point): AnyNode { + const { ductA } = joint() + return { ...(ductA as Record), path: [[-3, 0, 0], end] } as AnyNode + } - const connectivity = analyzePortConnectivity(ductA, nodes) - // The fitting follows rigidly… + test('the fitting and sibling run are picked up as carried connections', () => { + const { fitting, ductA, ductB } = joint() + const connectivity = analyzePortConnectivity(ductA, sceneOf(fitting, ductA, ductB)) expect( connectivity.connections.find((c) => c.kind === 'rigid-node' && c.nodeId === fitting.id), ).toBeDefined() - // …and duct B is picked up as a second-hop sibling endpoint. expect( - connectivity.connections.find( - (c) => c.kind === 'duct-endpoint-follow' && c.nodeId === ductB.id, - ), + connectivity.connections.find((c) => c.kind === 'run' && c.nodeId === ductB.id), ).toBeDefined() + }) - // Move duct A's mated endpoint (the 'end' port, path index 1) by +1 in Z. - const moved = { - ...(ductA as Record), - path: [ - [-3, 0, 0], - [-0.2, 0, 1], - ], - } as AnyNode - const updates = resolveConnectivityUpdates(connectivity, moved) + test('perpendicular drag translates the WHOLE sibling run (no skew)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + const connectivity = analyzePortConnectivity(ductA, nodes) - const fittingUpdate = updates.find((u) => u.id === fitting.id) - expect((fittingUpdate!.data as { position: Point }).position).toEqual([0, 0, 1]) + // Move A's mated end +1 in Z — perpendicular to B's X axis. + const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1])) - const bUpdate = updates.find((u) => u.id === ductB.id) - const bPath = (bUpdate!.data as { path: Point[] }).path - // Duct B's near end (index 0, on the outlet collar) rode +1 in Z… + expect( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + ).toEqual([0, 0, 1]) + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + // Both ends ride +1 in Z: the run keeps its length and direction. expect(bPath[0]).toEqual([0.2, 0, 1]) - // …its far end stayed put (the run stretches). + expect(bPath[1]).toEqual([3, 0, 1]) + }) + + test('parallel drag stretches the sibling run (only the near end slides)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + const connectivity = analyzePortConnectivity(ductA, nodes) + + // Move A's mated end +0.5 in X — along B's axis (the fitting slides toward B). + const updates = resolveConnectivityUpdates(connectivity, movedA([0.3, 0, 0])) + + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + // Near end slid +0.5 in X; far end stayed put → the run shortened. + expect(bPath[0]).toEqual([0.7, 0, 0]) expect(bPath[1]).toEqual([3, 0, 0]) }) + test('perpendicular slide propagates through the sibling run to its far joint', () => { + // Extend the chain: duct B's far end (3,0,0) meets a second elbow, and duct + // C hangs off that elbow. A perpendicular drag should carry the whole chain. + const { fitting, ductA, ductB } = joint() + const elbow2 = makeNode('duct-fitting', { position: [3.2, 0, 0], system: 'supply' }) + // elbow ports are ±0.2 on X around its position → inlet at (3,0,0) meets B. + const ductC = makeNode('duct-segment', { + path: [ + [3.4, 0, 0], + [6, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(fitting, ductA, ductB, elbow2, ductC) + const connectivity = analyzePortConnectivity(ductA, nodes) + + const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1])) + + // Whole chain rode +1 in Z. + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + expect(bPath[1]).toEqual([3, 0, 1]) + expect((updates.find((u) => u.id === elbow2.id)!.data as { position: Point }).position).toEqual( + [3.2, 0, 1], + ) + const cPath = (updates.find((u) => u.id === ductC.id)!.data as { path: Point[] }).path + expect(cPath[0]).toEqual([3.4, 0, 1]) + expect(cPath[1]).toEqual([6, 0, 1]) + }) + test('an unrelated run not on the fitting is left alone', () => { const { fitting, ductA, ductB } = joint() const distant = makeNode('duct-segment', { diff --git a/packages/core/src/services/port-connectivity.ts b/packages/core/src/services/port-connectivity.ts index b7907e586..cf5640af9 100644 --- a/packages/core/src/services/port-connectivity.ts +++ b/packages/core/src/services/port-connectivity.ts @@ -13,18 +13,29 @@ import type { AnyNode, AnyNodeId } from '../schema' * * Pure logic: it asks each node for its ports via `def.ports` (level-local * meters) and does arithmetic. No Three.js, no rendering — it lives in - * core and is consumed by the editor's move tool and the duct-segment - * system alike. + * core and is consumed by the editor's move tool and the duct/pipe + * selection affordances alike. * - * Propagation is intentionally bounded. A moved node stretches the ducts - * touching it (their near endpoint follows) and rigidly drags any fitting - * mated collar-to-collar. It also carries the *sibling* ducts on that - * dragged-along fitting — the other runs sharing the fitting's collars — - * so dragging one duct's corner moves the whole joint together instead of - * tearing the fitting away from its other legs. Their near endpoints - * translate with the fitting; their far ends stay put (they stretch). It - * stops there: it does NOT chase those far ends or hop onward through the - * next fitting. Bounded and predictable — no runaway network rearrangement. + * ## Propagation model + * + * The joint graph is snapshotted once at drag start (`analyzePortConnectivity`) + * and walked every frame (`resolveConnectivityUpdates`) given the moved node's + * live transform. Deltas flow outward from the moved node through coincident + * ports: + * + * - **Fitting** (rigid): a collar pushed by delta `d` translates the whole + * fitting by `d`; every other collar carries that same `d` onward. + * - **Run** (stretch + slide, never skew): an endpoint pushed by delta `d` is + * split against the run's own axis. The *parallel* part slides only that + * endpoint (the run lengthens / shortens); the *perpendicular* part + * translates the entire run (so its direction is preserved). The far + * endpoint therefore moves by just the perpendicular part, and that part + * propagates onward to whatever is mated to the far endpoint. + * + * Propagation walks the whole connected component so a joint stays welded all + * the way down the chain, with a visited guard so cycles (looped runs) and + * shared joints terminate. First-reached (shortest path) wins on a node + * reachable two ways. */ type Point = readonly [number, number, number] @@ -34,52 +45,54 @@ type Point = readonly [number, number, number] * generous slack for grid-snapped hand placement without false matches. */ const COINCIDENT_EPS_M = 0.05 -/** A node attached to one of the moved node's ports, plus how it follows. */ +/** Below this (meters) a propagated delta is treated as zero — stops the + * walk from chasing sub-millimeter perpendicular residue. */ +const DELTA_EPS_M = 1e-4 + +/** A node carried by the edit, plus the snapshot needed to revert it. Kept + * deliberately small: the move tools read only `kind` + `nodeId` and the + * matching start snapshot to revert before the single tracked commit. */ export type PortConnection = | { - /** Partner is a duct run: the endpoint touching the moved port slides - * to track it (one hop — the far endpoint stays put, stretching the - * run). */ - kind: 'duct-endpoint' - nodeId: AnyNodeId - /** Index in the duct's `path` that tracks the moved port. */ - pathIndex: number - /** The moved node's port id this endpoint follows. */ - movedPortId: string - /** The duct's full path at edit-start (other points are preserved). */ - startPath: Point[] - } - | { - /** Partner is another fitting mated collar-to-collar: it translates - * rigidly so its collar stays on the moved collar. */ + /** A fitting mated collar-to-collar: it translates rigidly. */ kind: 'rigid-node' nodeId: AnyNodeId - movedPortId: string - /** Partner node's `position` at edit-start. */ + /** Node's `position` at edit-start. */ startPosition: Point } | { - /** A sibling duct hanging off one of the dragged-along fitting's OTHER - * collars (second hop). The fitting follows the moved port rigidly, so - * this run's near endpoint translates by that same delta to stay welded - * to its collar — the whole joint moves together. The far endpoint - * stays put (the run stretches). */ - kind: 'duct-endpoint-follow' + /** A run whose endpoint(s) ride the edit: it stretches and/or + * translates, never skews. */ + kind: 'run' nodeId: AnyNodeId - /** Index in the run's `path` that rides the fitting. */ - pathIndex: number - /** The moved node's port id whose delta drives the fitting (and so this - * run's endpoint). */ - movedPortId: string - /** The run's full path at edit-start (other points are preserved). */ + /** The run's full `path` at edit-start. */ startPath: Point[] } +/** One node in the snapshotted joint graph (everything reachable from the + * moved node, excluding the moved node itself). */ +type GraphNode = { + id: AnyNodeId + role: 'run' | 'fitting' + ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + startPath?: Point[] + startPosition?: Point +} + +/** Who else sits on a given node's port, keyed `nodeId` → `portId` → mates. */ +type Adjacency = Record>> + export type PortConnectivity = { movedNodeId: AnyNodeId - /** The moved node's port world positions at edit-start, keyed by port id. - * Used as the reference each connection's delta is measured from. */ + /** The moved node's port world positions at edit-start, keyed by port id — + * the reference each frame's delta is measured from. */ startMovedPorts: Record + /** Reachable run/fitting nodes (excludes the moved node), keyed by id. */ + graph: Record + /** Port coincidence edges across the moved node + every graph node. */ + adjacency: Adjacency + /** Flat list of carried nodes for the move tools' revert + "anything to + * follow?" check. Derived from `graph`. */ connections: PortConnection[] } @@ -103,122 +116,149 @@ function distSq(a: Point, b: Point): number { return dx * dx + dy * dy + dz * dz } +/** Two ports mate when they coincide AND don't cross incompatible systems + * (a supply duct and a waste pipe that merely touch must not fuse). */ +function portsMate( + a: { position: Point; system?: string }, + b: { position: Point; system?: string }, + epsSq: number, +): boolean { + if (distSq(a.position, b.position) > epsSq) return false + if (a.system && b.system && a.system !== b.system) return false + return true +} + /** - * Snapshot which nodes are connected to `movedNode`'s ports, taken at the + * Snapshot the joint graph reachable from `movedNode`'s ports, taken at the * start of a move/resize. Call once before the drag; feed the result to * `resolveConnectivityUpdates` on every frame. * - * Only `run`-role partners (segments — endpoint stretch) and `fitting`-role - * partners (rigid follow) are tracked — terminals and equipment usually mount - * to a surface and shouldn't be yanked off it when an adjacent fitting nudges. + * Only `run`-role partners (segments) and `fitting`-role partners are walked — + * terminals and equipment usually mount to a surface and shouldn't be yanked + * off it when an adjacent fitting nudges. Fittings that declare + * `portConnectivityFollow: false` are anchored fixtures (e.g. pipe-trap) and + * are skipped, so a connected run stretches against them instead. */ export function analyzePortConnectivity( movedNode: AnyNode, nodes: Record, ): PortConnectivity { + const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M + const movedPorts = portsOf(movedNode) ?? [] const startMovedPorts: Record = {} - const movedPortSystem: Record = {} - for (const p of movedPorts) { - startMovedPorts[p.id] = p.position - movedPortSystem[p.id] = p.system - } - - const connections: PortConnection[] = [] - const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M + for (const p of movedPorts) startMovedPorts[p.id] = p.position + // Candidate partners: every run + every following fitting in the scene. + const candidates: GraphNode[] = [] for (const other of Object.values(nodes)) { if (!other || other.id === movedNode.id) continue - // Generalised across every distribution family (HVAC duct + DWV pipe): - // `run` partners stretch an endpoint, `fitting` partners follow rigidly. - // Terminals/equipment mount to surfaces and are intentionally NOT dragged. - // Fittings that declare `portConnectivityFollow: false` are anchored - // fixtures (e.g. pipe-trap) — moving a connected run stretches the arm. - const otherRole = roleOf(other) - if (otherRole !== 'run' && otherRole !== 'fitting') continue - const otherDef = nodeRegistry.get(other.type) - if (otherRole === 'fitting' && otherDef?.portConnectivityFollow === false) continue - const otherPorts = portsOf(other) - if (!otherPorts) continue - - for (const op of otherPorts) { - // Find which of the moved node's ports this partner port sits on. - let matchedId: string | null = null - for (const mp of movedPorts) { - if (distSq(op.position, mp.position) > epsSq) continue - // Don't fuse ports from incompatible systems (e.g. a supply duct - // and a waste pipe that happen to cross): only mate when both - // ports declare the same system, or at least one is unscoped. - const ms = movedPortSystem[mp.id] - if (ms && op.system && ms !== op.system) continue - matchedId = mp.id - break - } - if (!matchedId) continue - - if (otherRole === 'run') { - const path = (other as unknown as { path?: Point[] }).path - if (!Array.isArray(path) || path.length < 2) continue - // Port id 'start' → first point, 'end' → last point. - const pathIndex = op.id === 'start' ? 0 : path.length - 1 - connections.push({ - kind: 'duct-endpoint', - nodeId: other.id, - pathIndex, - movedPortId: matchedId, - startPath: path.map((p) => [...p] as Point), - }) - } else { - const position = (other as unknown as { position?: Point }).position - if (!position) continue - connections.push({ - kind: 'rigid-node', - nodeId: other.id, - movedPortId: matchedId, - startPosition: [position[0], position[1], position[2]], - }) - } + const role = roleOf(other) + if (role !== 'run' && role !== 'fitting') continue + if (role === 'fitting' && nodeRegistry.get(other.type)?.portConnectivityFollow === false) { + continue } + const ports = portsOf(other) + if (!ports) continue + const startPath = + role === 'run' + ? (other as unknown as { path?: Point[] }).path?.map((p) => [...p] as Point) + : undefined + if (role === 'run' && (!startPath || startPath.length < 2)) continue + const startPosition = + role === 'fitting' + ? (() => { + const pos = (other as unknown as { position?: Point }).position + return pos ? ([pos[0], pos[1], pos[2]] as Point) : undefined + })() + : undefined + if (role === 'fitting' && !startPosition) continue + candidates.push({ id: other.id as AnyNodeId, role, ports, startPath, startPosition }) } - // Second hop: each fitting we drag rigidly carries its OTHER runs along. - // Find every run endpoint sitting on one of that fitting's collars (apart - // from the run we're already moving) and drive it by the same port delta, - // so the whole joint translates together instead of the fitting peeling - // off its other legs. - const rigidFittings = connections.filter( - (c): c is Extract => c.kind === 'rigid-node', - ) - const alreadyTracked = new Set(connections.map((c) => c.nodeId)) - alreadyTracked.add(movedNode.id as AnyNodeId) - for (const fittingConn of rigidFittings) { - const fitting = nodes[fittingConn.nodeId] - if (!fitting) continue - const fittingPorts = portsOf(fitting) ?? [] - for (const fp of fittingPorts) { - for (const other of Object.values(nodes)) { - if (!other || alreadyTracked.has(other.id as AnyNodeId)) continue - if (roleOf(other) !== 'run') continue - const path = (other as unknown as { path?: Point[] }).path - if (!Array.isArray(path) || path.length < 2) continue - const otherPorts = portsOf(other) - if (!otherPorts) continue - const ep = otherPorts.find((p) => distSq(p.position, fp.position) <= epsSq) - if (!ep) continue - if (ep.system && fp.system && ep.system !== fp.system) continue - connections.push({ - kind: 'duct-endpoint-follow', - nodeId: other.id, - pathIndex: ep.id === 'start' ? 0 : path.length - 1, - movedPortId: fittingConn.movedPortId, - startPath: path.map((p) => [...p] as Point), - }) - alreadyTracked.add(other.id as AnyNodeId) + // Walk outward from the moved node, collecting every node reachable through + // coincident ports. The adjacency records each port's mates so the resolver + // can replay the same edges with live deltas. + const adjacency: Adjacency = {} + const addEdge = (nodeId: string, portId: string, mate: { nodeId: AnyNodeId; portId: string }) => { + const byPort = adjacency[nodeId] ?? {} + adjacency[nodeId] = byPort + const mates = byPort[portId] ?? [] + byPort[portId] = mates + mates.push(mate) + } + + const graph: Record = {} + const visited = new Set([movedNode.id]) + + // Seed: the moved node's own ports. + const queue: Array<{ + id: string + ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + }> = [{ id: movedNode.id, ports: movedPorts }] + + while (queue.length > 0) { + const { id, ports } = queue.shift()! + for (const port of ports) { + for (const cand of candidates) { + if (cand.id === id) continue + for (const cp of cand.ports) { + if (!portsMate(port, cp, epsSq)) continue + addEdge(id, port.id, { nodeId: cand.id, portId: cp.id }) + addEdge(cand.id, cp.id, { nodeId: id as AnyNodeId, portId: port.id }) + if (!visited.has(cand.id)) { + visited.add(cand.id) + graph[cand.id] = cand + queue.push({ id: cand.id, ports: cand.ports }) + } + } } } } - return { movedNodeId: movedNode.id as AnyNodeId, connections, startMovedPorts } + const connections: PortConnection[] = Object.values(graph).map((g) => + g.role === 'fitting' + ? { kind: 'rigid-node', nodeId: g.id, startPosition: g.startPosition! } + : { kind: 'run', nodeId: g.id, startPath: g.startPath! }, + ) + + return { + movedNodeId: movedNode.id as AnyNodeId, + startMovedPorts, + graph, + adjacency, + connections, + } +} + +function add(a: Point, b: Point): Point { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +function sub(a: Point, b: Point): Point { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +function lenSq(v: Point): number { + return v[0] * v[0] + v[1] * v[1] + v[2] * v[2] +} + +/** Split `delta` into the component along unit `axis` and the remainder. */ +function decompose(delta: Point, axis: Point): { parallel: Point; perp: Point } { + const dot = delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2] + const parallel: Point = [axis[0] * dot, axis[1] * dot, axis[2] * dot] + return { parallel, perp: sub(delta, parallel) } +} + +/** Unit direction of the run's segment adjacent to its `start` / `end` tip. */ +function endpointAxis(path: Point[], portId: string): Point { + const n = path.length + const [a, b] = portId === 'start' ? [path[1]!, path[0]!] : [path[n - 2]!, path[n - 1]!] + const dir = sub(b, a) + const l2 = lenSq(dir) + if (l2 < 1e-12) return [0, 0, 0] + const l = Math.sqrt(l2) + return [dir[0] / l, dir[1] / l, dir[2] / l] } /** @@ -226,55 +266,77 @@ export function analyzePortConnectivity( * that keep every connected node attached. `previewNode` is the moved node * with its current drag position/rotation applied so its ports recompute. * - * - Duct endpoint: set the tracked path point to the moved port's new - * position (the joint stays welded; the run stretches). - * - Rigid fitting: translate by the moved port's delta so its mated collar - * rides along. + * Walks the snapshotted graph, propagating each port delta outward: fittings + * translate rigidly, runs stretch along their axis and translate across it + * (never skew), and the perpendicular part of a run's slide carries on to its + * far joint. Visited guard bounds cycles and shared joints. */ export function resolveConnectivityUpdates( connectivity: PortConnectivity, previewNode: AnyNode, ): { id: AnyNodeId; data: Partial }[] { + const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity + if (Object.keys(graph).length === 0) return [] + const newPorts = portsOf(previewNode) ?? [] - const newById: Record = {} - for (const p of newPorts) newById[p.id] = p.position - - const updates: { id: AnyNodeId; data: Partial }[] = [] - for (const conn of connectivity.connections) { - const start = connectivity.startMovedPorts[conn.movedPortId] - const now = newById[conn.movedPortId] - if (!start || !now) continue - - if (conn.kind === 'duct-endpoint') { - const path = conn.startPath.map((p, i) => - i === conn.pathIndex ? ([now[0], now[1], now[2]] as Point) : ([...p] as Point), - ) - updates.push({ id: conn.nodeId, data: { path } as Partial }) - } else if (conn.kind === 'duct-endpoint-follow') { - // Sibling run on a dragged-along fitting: translate its near endpoint by - // the same port delta the fitting follows. Far end stays put (stretch). - const dx = now[0] - start[0] - const dy = now[1] - start[1] - const dz = now[2] - start[2] - const path = conn.startPath.map((p, i) => - i === conn.pathIndex ? ([p[0] + dx, p[1] + dy, p[2] + dz] as Point) : ([...p] as Point), - ) - updates.push({ id: conn.nodeId, data: { path } as Partial }) + const newMovedPos: Record = {} + for (const p of newPorts) newMovedPos[p.id] = p.position + + // Each queue item drives a node's port by a delta ("this collar / endpoint + // must move by this much"). Visited nodes resolve once (shortest path wins). + const queue: Array<{ nodeId: AnyNodeId; portId: string; delta: Point }> = [] + const results: Record }> = {} + const visited = new Set([movedNodeId]) + + const enqueueMates = (nodeId: string, portId: string, delta: Point) => { + for (const mate of adjacency[nodeId]?.[portId] ?? []) { + if (visited.has(mate.nodeId)) continue + queue.push({ nodeId: mate.nodeId, portId: mate.portId, delta }) + } + } + + // Seed from the moved node's live port deltas. + for (const [portId, start] of Object.entries(startMovedPorts)) { + const now = newMovedPos[portId] + if (!now) continue + const delta = sub(now, start) + if (lenSq(delta) <= DELTA_EPS_M * DELTA_EPS_M) continue + enqueueMates(movedNodeId, portId, delta) + } + + while (queue.length > 0) { + const { nodeId, portId, delta } = queue.shift()! + if (visited.has(nodeId)) continue + visited.add(nodeId) + const node = graph[nodeId] + if (!node) continue + + if (node.role === 'fitting') { + const start = node.startPosition! + results[nodeId] = { id: nodeId, data: { position: add(start, delta) } as Partial } + // Rigid: every other collar carries the same delta onward. + for (const p of node.ports) { + if (p.id === portId) continue + enqueueMates(nodeId, p.id, delta) + } } else { - const dx = now[0] - start[0] - const dy = now[1] - start[1] - const dz = now[2] - start[2] - updates.push({ - id: conn.nodeId, - data: { - position: [ - conn.startPosition[0] + dx, - conn.startPosition[1] + dy, - conn.startPosition[2] + dz, - ], - } as Partial, - }) + const startPath = node.startPath! + const nearIdx = portId === 'start' ? 0 : startPath.length - 1 + const farPortId = portId === 'start' ? 'end' : 'start' + const axis = endpointAxis(startPath, portId) + const { parallel, perp } = decompose(delta, axis) + // Perpendicular part translates the whole run (preserves direction); + // the parallel part slides only the dragged endpoint (stretch). + const path = startPath.map((p) => add(p, perp)) + path[nearIdx] = add(path[nearIdx]!, parallel) + results[nodeId] = { id: nodeId, data: { path } as Partial } + // The far endpoint moved by just the perpendicular part — carry it on + // so the joint downstream stays welded. + if (lenSq(perp) > DELTA_EPS_M * DELTA_EPS_M) { + enqueueMates(nodeId, farPortId, perp) + } } } - return updates + + return Object.values(results) } diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index caf71ffe2..907646800 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -64,7 +64,7 @@ const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3 const ROTATE_RIBBON_HALF_WIDTH = 0.02 const ROTATE_HEAD_HALF_WIDTH = 0.045 const TRACKER_CUBE_SIZE = 0.16 -export const CORNER_HEX_RADIUS = 0.16 +export const CORNER_HEX_RADIUS = 0.11 export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker' export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross' diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 1840e4597..97d7d6e79 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -54,7 +54,7 @@ const ARROW_HOVER_COLOR = '#a5b4fc' // Match the door arrows: scale the rendered chevron down to ~two-thirds // so the in-world handles read as a single UI family. const ARROW_SCALE = 0.65 -const CORNER_HEX_RADIUS = 0.16 +const CORNER_HEX_RADIUS = 0.11 const CORNER_DASH_SIZE = 0.1 const CORNER_GAP_SIZE = 0.07 const CORNER_DASH_THICKNESS = 0.006 diff --git a/packages/nodes/src/duct-fitting/move-tool.tsx b/packages/nodes/src/duct-fitting/move-tool.tsx index f378d6221..9206e64d1 100644 --- a/packages/nodes/src/duct-fitting/move-tool.tsx +++ b/packages/nodes/src/duct-fitting/move-tool.tsx @@ -27,6 +27,7 @@ import { collectGhostAlignmentCandidates, resolveGhostAlignment, } from '../shared/ghost-alignment' +import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity' import { buildDuctFittingGeometry } from './geometry' type Vec3 = [number, number, number] @@ -34,6 +35,12 @@ type Vec3 = [number, number, number] const GHOST_COLOR = '#818cf8' const GHOST_OPACITY = 0.5 +/** Screen pixels → meters for the Ctrl-vertical (riser) drag — matches the + * duct draw tool's Alt-vertical feel. 100 px ≈ 1 m. */ +const VERTICAL_PIXELS_PER_METER = 100 +const VERTICAL_Y_MIN_M = -3 +const VERTICAL_Y_MAX_M = 10 + /** Snap a coordinate to the editor's live grid step. */ function snapToGridStep(value: number): number { const step = useEditor.getState().gridSnapStep @@ -174,36 +181,81 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => { } if (existedAtStart) setMeshHidden(true) + // Carry connected ducts as the fitting slides: the part of the move along + // a run's axis stretches it, the part across translates the whole run (and + // propagates to its far joint). Snapshot once at drag start; only existing + // fittings are mated to anything. + const connectivity: RunMoveConnectivity | null = existedAtStart + ? startRunMoveConnectivity(node) + : null + let lastPos: Vec3 = originalPosition + // Tracks whether the last frame held Alt: the fitting is detached from its + // connected ducts for the drag, so they stay put (no follow) and the + // commit omits their updates. Mirrors the duct endpoint's Alt-detach. + let lastDetached = false + // Anchor for the Ctrl-vertical (riser) drag: clientY + base Y captured the + // frame Ctrl is first held, so vertical mouse motion maps to Y. Cleared + // when Ctrl is released. Mirrors the draw tool's Alt-vertical anchor. + let verticalAnchor: { clientY: number; baseY: number } | null = null const onMove = (event: GridEvent) => { const bypass = event.nativeEvent?.shiftKey === true + // Alt = detach: drop the connected-duct follow so the fitting moves on + // its own, leaving every mated run where it sits. + const detached = event.nativeEvent?.altKey === true + // Ctrl/Cmd = vertical: XZ locks to where the fitting sits and the cursor's + // screen-Y drives the riser height (connected ducts still follow). + const vertical = event.nativeEvent?.ctrlKey === true || event.nativeEvent?.metaKey === true + const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY const snap = bypass ? (v: number) => v : snapToGridStep - let x = snap(event.localPosition[0]) - let z = snap(event.localPosition[2]) - // Alignment: snap the footprint box edges onto nearby geometry and - // publish guides (Alt / Shift bypass). - if (!bypass) { - const proposed: Aabb2D = { - minX: x + ox - hx, - maxX: x + ox + hx, - minZ: z + oz - hz, - maxZ: z + oz + hz, - } - const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates) - x += dx - z += dz - useAlignmentGuides.getState().set(guides) - } else { + let next: Vec3 + if (vertical && typeof clientY === 'number') { + if (!verticalAnchor) verticalAnchor = { clientY, baseY: lastPos[1] } + // Screen +Y points down, so subtract to map "drag up = raise". + const dy = (verticalAnchor.clientY - clientY) / VERTICAL_PIXELS_PER_METER + const y = Math.min( + VERTICAL_Y_MAX_M, + Math.max(VERTICAL_Y_MIN_M, verticalAnchor.baseY + snap(dy)), + ) + next = [lastPos[0], y, lastPos[2]] useAlignmentGuides.getState().clear() + } else { + verticalAnchor = null + let x = snap(event.localPosition[0]) + let z = snap(event.localPosition[2]) + + // Alignment: snap the footprint box edges onto nearby geometry and + // publish guides (Alt / Shift bypass). + if (!bypass) { + const proposed: Aabb2D = { + minX: x + ox - hx, + maxX: x + ox + hx, + minZ: z + oz - hz, + maxZ: z + oz + hz, + } + const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates) + x += dx + z += dz + useAlignmentGuides.getState().set(guides) + } else { + useAlignmentGuides.getState().clear() + } + next = [x, lastPos[1], z] } - const next: Vec3 = [x, originalPosition[1], z] - if (next[0] !== lastPos[0] || next[2] !== lastPos[2]) triggerSFX('sfx:grid-snap') + if (next[0] !== lastPos[0] || next[1] !== lastPos[1] || next[2] !== lastPos[2]) { + triggerSFX('sfx:grid-snap') + } lastPos = next + lastDetached = detached hasMoved = true setCursorPos(next) + // Detached: keep the followers at their origin (drop any live overrides + // from a prior non-detached frame). Otherwise preview the follow. + if (detached) connectivity?.clear() + else connectivity?.preview({ position: next }) } const commit = (event: GridEvent) => { @@ -230,10 +282,24 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => { useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) selectId = created.id as AnyNodeId } else { - useScene.getState().updateNode(nodeId, { position: lastPos } as Partial) + // Fold connected-duct / sibling-run follow-updates into the SAME batch + // as the moved fitting so the whole joint is one undo step. Detached + // (Alt on the final frame): the joint is broken, so nothing follows. + const followUpdates = lastDetached + ? [] + : (connectivity?.commitUpdates({ position: lastPos }) ?? []) + useScene + .getState() + .updateNodes([ + { id: nodeId, data: { position: lastPos } as Partial }, + ...followUpdates, + ]) useScene.getState().markDirty(nodeId) } useScene.temporal.getState().pause() + // Followers are committed to the store — drop their live overrides so + // renderers read the canonical path/position. + connectivity?.clear() setMeshHidden(false) useAlignmentGuides.getState().clear() @@ -245,6 +311,7 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => { } const onCancel = () => { + connectivity?.clear() if (existedAtStart) { setMeshHidden(false) useViewer.getState().setSelection({ selectedIds: [nodeId] }) @@ -264,6 +331,7 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => { emitter.off('grid:move', onMove) emitter.off('grid:click', commit) emitter.off('tool:cancel', onCancel) + connectivity?.clear() useAlignmentGuides.getState().clear() if (existedAtStart) setMeshHidden(false) useScene.temporal.getState().resume() diff --git a/packages/nodes/src/duct-segment/move-tool.tsx b/packages/nodes/src/duct-segment/move-tool.tsx index 8b81a516d..0c2c74407 100644 --- a/packages/nodes/src/duct-segment/move-tool.tsx +++ b/packages/nodes/src/duct-segment/move-tool.tsx @@ -27,6 +27,7 @@ import { collectGhostAlignmentCandidates, resolveGhostAlignment, } from '../shared/ghost-alignment' +import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity' import { rectSectionAxes } from './geometry' type Vec3 = [number, number, number] @@ -138,6 +139,12 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { } if (existedAtStart) setMeshHidden(true) + // Carry connected fittings (+ their other runs) as the whole run slides. + // Snapshot once at drag start; only existing runs are mated to anything. + const connectivity: RunMoveConnectivity | null = existedAtStart + ? startRunMoveConnectivity(node) + : null + const setPreview = (path: Vec3[]) => { previewPathRef.current = path setPreviewPath(path) @@ -177,7 +184,9 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { } prevSnapRef.current = cur hasMovedRef.current = true - setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)) + const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3) + setPreview(nextPath) + connectivity?.preview({ path: nextPath }) } const commit = (event: GridEvent) => { @@ -205,10 +214,21 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) selectId = created.id as AnyNodeId } else { - useScene.getState().updateNode(nodeId, { path: finalPath } as Partial) + // Fold connected-fitting / sibling-run follow-updates into the SAME + // batch as the moved run so the whole joint is one undo step. + const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? [] + useScene + .getState() + .updateNodes([ + { id: nodeId, data: { path: finalPath } as Partial }, + ...followUpdates, + ]) useScene.getState().markDirty(nodeId) } useScene.temporal.getState().pause() + // Followers are committed to the store — drop their live overrides so + // renderers read the canonical path/position. + connectivity?.clear() setMeshHidden(false) useAlignmentGuides.getState().clear() @@ -220,6 +240,7 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { } const onCancel = () => { + connectivity?.clear() if (existedAtStart) { setMeshHidden(false) useViewer.getState().setSelection({ selectedIds: [nodeId] }) @@ -239,6 +260,7 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { emitter.off('grid:move', onMove) emitter.off('grid:click', commit) emitter.off('tool:cancel', onCancel) + connectivity?.clear() useAlignmentGuides.getState().clear() if (existedAtStart) setMeshHidden(false) useScene.temporal.getState().resume() diff --git a/packages/nodes/src/duct-segment/selection.tsx b/packages/nodes/src/duct-segment/selection.tsx index 15011eef7..d6626c16b 100644 --- a/packages/nodes/src/duct-segment/selection.tsx +++ b/packages/nodes/src/duct-segment/selection.tsx @@ -15,13 +15,29 @@ import { import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' -import { useEffect, useRef, useState } from 'react' -import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' +import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + DoubleSide, + type Group, + type Object3D, + Plane, + Quaternion, + Raycaster, + Vector2, + Vector3, +} from 'three' +import { + type DuctElbowEndpoint, + detectDuctElbowEndpoint, + planDuctElbowEndpointReaim, +} from '../shared/elbow-endpoint-reaim' import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' -/** Handle pip radius (meters). */ -const HANDLE_RADIUS = 0.09 +/** Corner hex-disc radius (meters) — matches the wall corner picker. */ +const HANDLE_RADIUS = 0.11 +const HANDLE_COLOR = '#818cf8' +const HANDLE_HOVER_COLOR = '#a5b4fc' /** Port-snap radius for dragged run endpoints (meters, XZ). */ const PORT_SNAP_RADIUS_M = 0.4 @@ -44,14 +60,20 @@ type Point = [number, number, number] * Drag raycasts run in world space and convert hits back into the * group's local frame before writing the path. * - * Drag model: by default the point is CONSTRAINED to the axis the - * segment was drawn along — a horizontal duct's endpoint slides along - * its own length, a riser's endpoint slides vertically. Holding **Alt** - * releases the constraint into free horizontal-plane movement (at the - * point's height); in free mode dragged run endpoints (first / last - * point) also snap onto nearby typed ports so a loose run can be mated - * onto a fitting after the fact. Holding **Shift** bypasses grid - * snapping in either mode for a perfectly smooth precision drag. + * Drag model: the point moves FREELY on the horizontal plane at its own + * height (no axis lock) — like a wall corner. Dragged run endpoints snap + * onto nearby typed ports so a loose run can be mated onto a fitting after + * the fact. When the dragged endpoint belongs to a straight run whose OTHER + * end sits on an elbow collar, the elbow re-aims to follow the drag + * (junction + far collar fixed, bend angle adapts) instead of port-snapping. + * + * Modifiers (mirroring the wall corner drag): + * - **Alt** detaches: the joint breaks for this drag — the elbow does NOT + * re-aim and mated fittings / runs do NOT follow; the endpoint moves on its + * own (port re-mate still allowed so it can be reattached elsewhere). + * - **Cmd / Ctrl** switches to vertical movement (riser editing): XZ holds + * and the cursor drives Y. + * - **Shift** bypasses grid snapping for a perfectly smooth precision drag. * * History does the single-undo dance: paused during the drag (the live * `updateNode` ticks are untracked), then on release the path is @@ -105,6 +127,14 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj // Connectivity snapshot taken at pointer-down: which fittings / ducts are // mated to this run's endpoints, so they follow as the endpoint moves. connectivity: PortConnectivity | null + // Set when the run's OTHER end sits on an elbow collar: the elbow re-aims + // to follow this drag instead of translating rigidly (mutually exclusive + // with `connectivity`-driven follow for this endpoint). + elbowEndpoint: DuctElbowEndpoint | null + // True while Alt is held: the joint is detached for this drag, so the + // final commit must omit elbow / connectivity updates. Tracked live so + // `onUp` knows what the last frame did. + detached: boolean } | null>(null) const makeRay = (clientX: number, clientY: number) => { @@ -124,24 +154,50 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj } /** - * Signed distance along `axisWorld` (unit, through `anchorWorld`) of the - * point on that line closest to the cursor ray. Null when the ray runs - * (near-)parallel to the axis and the projection is unstable. + * Local-frame Y where the cursor ray meets a vertical plane through + * `anchorWorld` that faces the camera — drives Alt-vertical (riser) drag. + * Null when the ray is parallel to the plane. */ - const projectOntoAxis = ( + const intersectVerticalY = ( clientX: number, clientY: number, anchorWorld: Vector3, - axisWorld: Vector3, ): number | null => { - const ray = makeRay(clientX, clientY) - const w0 = new Vector3().subVectors(ray.origin, anchorWorld) - const b = ray.direction.dot(axisWorld) - const denom = 1 - b * b - if (Math.abs(denom) < 1e-6) return null - const d0 = ray.direction.dot(w0) - const e0 = axisWorld.dot(w0) - return (e0 - b * d0) / denom + // Plane normal: camera forward flattened onto the horizontal plane, so + // the plane stands upright through the point and faces the viewer. + const forward = camera.getWorldDirection(new Vector3()) + forward.y = 0 + if (forward.lengthSq() < 1e-6) forward.set(0, 0, 1) + forward.normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(forward, anchorWorld) + const hit = intersect(clientX, clientY, plane) + return hit ? toLocal(hit)[1] : null + } + + // Build the per-frame update batch for the dragged endpoint at `next`. + // Detached (Alt): only the duct path moves — no elbow re-aim, no + // connectivity follow. Elbow mode: the run rides the elbow's re-aimed + // collar and the elbow swings to fit. Otherwise: the dragged point moves + // and any mated fittings / runs translate via connectivity. + const buildDragBatch = ( + drag: NonNullable, + next: Point, + detached: boolean, + ): { id: AnyNodeId; data: Partial }[] | null => { + if (!detached && drag.elbowEndpoint) { + const plan = planDuctElbowEndpointReaim(drag.elbowEndpoint, drag.index, next) + // Out of the elbow's buildable turn range — hold this frame. + if (!plan) return null + return [ + { id: duct.id as AnyNodeId, data: { path: plan.path } }, + { id: plan.elbowUpdate.id, data: plan.elbowUpdate.data as Partial }, + ] + } + const path = duct.path.map((p, i) => (i === drag.index ? next : p)) as Point[] + return [ + { id: duct.id as AnyNodeId, data: { path } }, + ...(detached ? [] : connectivityUpdatesForPath(drag.connectivity, path)), + ] } /** World-space position of a local path point. */ @@ -178,27 +234,14 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj const isEndpoint = index === 0 || index === initialPath.length - 1 - // Axis the segment was drawn along, at this point: from the - // neighbouring path point toward the dragged one. The default drag - // is constrained to this line. - const neighbor = initialPath[index === 0 ? 1 : index - 1]! - const axisLocal = new Vector3( - startPoint[0] - neighbor[0], - startPoint[1] - neighbor[1], - startPoint[2] - neighbor[2], - ) - if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0) - axisLocal.normalize() - // World-space anchor + axis, derived once — the constraint line is - // fixed for the whole drag regardless of where the point currently is. - const anchorWorldStart = toWorld(startPoint) - const axisWorld = toWorld([ - startPoint[0] + axisLocal.x, - startPoint[1] + axisLocal.y, - startPoint[2] + axisLocal.z, - ]) - .sub(anchorWorldStart) - .normalize() + // Elbow re-aim: if this is a straight run whose OTHER end sits on an + // elbow collar, the elbow swings to follow the drag (junction + far + // collar fixed, bend angle adapts) — so the dragged end moves freely in + // any direction instead of being locked to the segment's own axis, the + // way a wall corner drags. Detected once against a drag-start snapshot. + const elbowEndpoint: DuctElbowEndpoint | null = isEndpoint + ? detectDuctElbowEndpoint(initialPath, index, useScene.getState().nodes) + : null const onMove = (event: PointerEvent) => { const drag = dragRef.current @@ -207,16 +250,27 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj // Shift = precision: bypass grid snapping for a perfectly smooth // drag (snap() is a no-op at step 0). const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep + // Alt = detach: break the joint for this drag — the endpoint moves on + // its own, no elbow re-aim and no connectivity follow (it can still + // port-snap to re-mate elsewhere). Mirrors the wall corner drag. + const detached = event.altKey let next: Point | null = null - if (event.altKey) { - // Alt = freedom: slide on the horizontal plane at the point's - // height. Endpoints can port-snap here to mate onto a fitting. + if (event.metaKey || event.ctrlKey) { + // Cmd/Ctrl = vertical: keep XZ fixed and drive Y off the cursor + // against a vertical plane through the point (riser editing). + const y = intersectVerticalY(event.clientX, event.clientY, toWorld(current)) + if (y !== null) next = [current[0], Math.max(0, snap(y, step)), current[2]] + } else { + // Default: free movement on the horizontal plane at the point's + // height (no axis lock). Endpoints can port-snap to mate a fitting. const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current)) const hit = intersect(event.clientX, event.clientY, plane) if (hit) { const local = toLocal(hit) next = [snap(local[0], step), current[1], snap(local[2], step)] - if (isEndpoint) { + // Port re-mate stays available whether detaching or free-dragging; + // it's only suppressed while the elbow is actively re-aiming. + if (isEndpoint && (detached || !drag.elbowEndpoint)) { const port = findNearestPortXZ( [local[0], current[1], local[2]], collectScenePorts({ excludeNodeId: duct.id, systems: DUCT_PORT_SYSTEMS }), @@ -225,30 +279,14 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj if (port) next = [port.position[0], port.position[1], port.position[2]] } } - } else { - // Default: constrained to the axis the segment was drawn along — - // slide the point closer / further along its own line. - const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld) - if (t !== null) { - const dist = snap(t, step) - next = [ - startPoint[0] + axisLocal.x * dist, - Math.max(0, startPoint[1] + axisLocal.y * dist), - startPoint[2] + axisLocal.z * dist, - ] - } } if (!next) return if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return + const batch = buildDragBatch(drag, next, detached) + if (!batch) return drag.current = next - const path = duct.path.map((p, i) => (i === drag.index ? next! : p)) as Point[] - // Drag the run + any fittings mated to the moved endpoint as one batch. - useScene - .getState() - .updateNodes([ - { id: duct.id as AnyNodeId, data: { path } }, - ...connectivityUpdatesForPath(drag.connectivity, path), - ]) + drag.detached = detached + useScene.getState().updateNodes(batch) } const onUp = () => { @@ -257,19 +295,32 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj drag.cleanup() dragRef.current = null setDraggingIndex(null) - // Single-undo dance: revert (still paused), resume, re-apply the - // final path — plus any connected fitting moves — as one tracked batch. - const finalPath = drag.initialPath.map((p, i) => - i === drag.index ? drag.current : p, - ) as Point[] - const finalUpdates = connectivityUpdatesForPath(drag.connectivity, finalPath) - // Revert the run AND the followers to their pre-drag state while paused - // so history captures a clean before→after delta. - const revertUpdates = (drag.connectivity?.connections ?? []).flatMap((conn) => - conn.kind === 'rigid-node' - ? [{ id: conn.nodeId, data: { position: conn.startPosition } as Partial }] - : [{ id: conn.nodeId, data: { path: conn.startPath } as Partial }], - ) + // Single-undo dance: revert (still paused), resume, re-apply the final + // batch as one tracked change. The final batch is built the same way as + // each live frame (elbow re-aim, rigid connectivity follow, or — when + // detached — just the duct path). + const detached = drag.detached + const finalBatch = buildDragBatch(drag, drag.current, detached) + // Revert the run AND whatever the drag carried to their pre-drag state + // while paused so history captures a clean before→after delta. When + // detached nothing else moved, so only the run needs reverting. + const revertUpdates: { id: AnyNodeId; data: Partial }[] = detached + ? [] + : drag.elbowEndpoint + ? [ + { + id: drag.elbowEndpoint.elbow.id as AnyNodeId, + data: { + angle: drag.elbowEndpoint.elbow.angle, + rotation: drag.elbowEndpoint.elbow.rotation, + } as Partial, + }, + ] + : (drag.connectivity?.connections ?? []).map((conn) => + conn.kind === 'rigid-node' + ? { id: conn.nodeId, data: { position: conn.startPosition } as Partial } + : { id: conn.nodeId, data: { path: conn.startPath } as Partial }, + ) useScene .getState() .updateNodes([ @@ -277,13 +328,9 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj ...revertUpdates.filter((u) => useScene.getState().nodes[u.id]), ]) resumeSceneHistory(useScene) - const moved = finalPath[drag.index]!.some( - (v, axis) => v !== drag.initialPath[drag.index]![axis], - ) - if (moved) { - useScene - .getState() - .updateNodes([{ id: duct.id as AnyNodeId, data: { path: finalPath } }, ...finalUpdates]) + const moved = drag.current.some((v, axis) => v !== drag.initialPath[drag.index]![axis]) + if (moved && finalBatch) { + useScene.getState().updateNodes(finalBatch) } } @@ -295,7 +342,15 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj document.body.style.cursor = '' } - dragRef.current = { index, initialPath, current: startPoint, cleanup, connectivity } + dragRef.current = { + index, + initialPath, + current: startPoint, + cleanup, + connectivity, + elbowEndpoint, + detached: false, + } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onUp) @@ -303,35 +358,24 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj return ( - {duct.path.map((p, i) => { - const active = draggingIndex === i - const hovered = hoverIndex === i - return ( - { - e.stopPropagation() - setHoverIndex(i) - if (draggingIndex === null) document.body.style.cursor = 'grab' - }} - onPointerLeave={() => { - setHoverIndex((prev) => (prev === i ? null : prev)) - if (draggingIndex === null) document.body.style.cursor = '' - }} - position={p as Point} - > - - - - ) - })} + {duct.path.map((p, i) => ( + { + e.stopPropagation() + setHoverIndex(i) + if (draggingIndex === null) document.body.style.cursor = 'grab' + }} + onPointerLeave={() => { + setHoverIndex((prev) => (prev === i ? null : prev)) + if (draggingIndex === null) document.body.style.cursor = '' + }} + position={p as Point} + /> + ))} {draggingIndex !== null && duct.path[draggingIndex] && (() => { @@ -368,4 +412,79 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj ) } +/** + * Billboarded hexagon disc handle for a duct path vertex — the same visual + * the wall corner picker uses, so corner editing reads consistently across + * kinds. A flat `CircleGeometry` with 6 segments is the click target; an + * outer hex ring frames it. The group copies the camera's WORLD rotation + * (compensating for the rotated duct/level parent) so the hex stays + * face-on at any viewing angle. + */ +function HexHandle({ + position, + active, + hovered, + onPointerDown, + onPointerEnter, + onPointerLeave, +}: { + position: Point + active: boolean + hovered: boolean + onPointerDown: (e: ThreeEvent) => void + onPointerEnter: (e: ThreeEvent) => void + onPointerLeave: () => void +}) { + const { camera } = useThree() + const groupRef = useRef(null) + const parentWorldQuat = useMemo(() => new Quaternion(), []) + const invParentWorldQuat = useMemo(() => new Quaternion(), []) + useFrame(() => { + const group = groupRef.current + if (!group) return + if (group.parent) { + group.parent.getWorldQuaternion(parentWorldQuat) + invParentWorldQuat.copy(parentWorldQuat).invert() + group.quaternion.copy(invParentWorldQuat).multiply(camera.quaternion) + } else { + group.quaternion.copy(camera.quaternion) + } + }) + + const color = active || hovered ? HANDLE_HOVER_COLOR : HANDLE_COLOR + const scale = hovered || active ? 1.25 : 1 + + return ( + + + + + + + + + + + ) +} + export default DuctSegmentSelectionAffordance diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index 194421490..f078d80fa 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -105,6 +105,11 @@ const ALT_PIXELS_PER_METER = 100 const ALT_Y_MIN_M = -3 const ALT_Y_MAX_M = 10 +/** green-500 — the project's bounding-box / placeable accent. The cursor + * ring + vertical line recolour to this while the point is snapped onto an + * existing run, so the coincidence reads with the familiar snap green. */ +const SNAP_CURSOR_COLOR = '#22c55e' + function snap(value: number, step: number): number { if (step <= 0) return value return Math.round(value / step) * step @@ -296,8 +301,9 @@ const DuctSegmentTool = () => { // Ceiling mode (toggle with C): the first point lands at the level's // ceiling height (duct top hugging the ceiling) instead of the floor. const [ceilingMode, setCeilingMode] = useState(false) - // When the cursor is within snap range of an existing duct's endpoint we - // surface a brighter indicator and commit at the endpoint's exact coords. + // The shared coordinate when the cursor is within snap range of an existing + // duct (null = free placement). Drives the green cursor highlight so the + // user sees the next click will join an existing run, not freeform-place. const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null) // In ceiling mode, the ceiling the cursor is currently under — rendered as // a translucent overlay so the duct reads as hung against a real surface @@ -939,13 +945,18 @@ const DuctSegmentTool = () => { fixed-height cursor. */} {isElevated ? ( ) : ( - + )} {pillParts && ( @@ -968,15 +979,6 @@ const DuctSegmentTool = () => { )} )} - {/* Endpoint-snap halo — brighter ring around the target endpoint - while the cursor is within snap range, so the user sees that the - next click will join an existing duct rather than freeform-place. */} - {snapTarget && ( - - - - - )} {/* Committed point pips */} {draftPoints.map((p, i) => ( diff --git a/packages/nodes/src/lineset/move-tool.tsx b/packages/nodes/src/lineset/move-tool.tsx index a025b5348..47c4a3434 100644 --- a/packages/nodes/src/lineset/move-tool.tsx +++ b/packages/nodes/src/lineset/move-tool.tsx @@ -27,6 +27,7 @@ import { collectGhostAlignmentCandidates, resolveGhostAlignment, } from '../shared/ghost-alignment' +import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity' type Vec3 = [number, number, number] @@ -137,6 +138,12 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => { } if (existedAtStart) setMeshHidden(true) + // Carry connected fittings (+ their other runs) as the whole run slides. + // Snapshot once at drag start; only existing runs are mated to anything. + const connectivity: RunMoveConnectivity | null = existedAtStart + ? startRunMoveConnectivity(node) + : null + const setPreview = (path: Vec3[]) => { previewPathRef.current = path setPreviewPath(path) @@ -176,7 +183,9 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => { } prevSnapRef.current = cur hasMovedRef.current = true - setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)) + const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3) + setPreview(nextPath) + connectivity?.preview({ path: nextPath }) } const commit = (event: GridEvent) => { @@ -204,10 +213,21 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => { useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) selectId = created.id as AnyNodeId } else { - useScene.getState().updateNode(nodeId, { path: finalPath } as Partial) + // Fold connected-fitting / sibling-run follow-updates into the SAME + // batch as the moved run so the whole joint is one undo step. + const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? [] + useScene + .getState() + .updateNodes([ + { id: nodeId, data: { path: finalPath } as Partial }, + ...followUpdates, + ]) useScene.getState().markDirty(nodeId) } useScene.temporal.getState().pause() + // Followers are committed to the store — drop their live overrides so + // renderers read the canonical path/position. + connectivity?.clear() setMeshHidden(false) useAlignmentGuides.getState().clear() @@ -219,6 +239,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => { } const onCancel = () => { + connectivity?.clear() if (existedAtStart) { setMeshHidden(false) useViewer.getState().setSelection({ selectedIds: [nodeId] }) @@ -238,6 +259,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => { emitter.off('grid:move', onMove) emitter.off('grid:click', commit) emitter.off('tool:cancel', onCancel) + connectivity?.clear() useAlignmentGuides.getState().clear() if (existedAtStart) setMeshHidden(false) useScene.temporal.getState().resume() diff --git a/packages/nodes/src/pipe-segment/move-tool.tsx b/packages/nodes/src/pipe-segment/move-tool.tsx index cfa93e392..b041c23d0 100644 --- a/packages/nodes/src/pipe-segment/move-tool.tsx +++ b/packages/nodes/src/pipe-segment/move-tool.tsx @@ -27,6 +27,7 @@ import { collectGhostAlignmentCandidates, resolveGhostAlignment, } from '../shared/ghost-alignment' +import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity' type Vec3 = [number, number, number] @@ -135,6 +136,12 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { } if (existedAtStart) setMeshHidden(true) + // Carry connected fittings (+ their other runs) as the whole run slides. + // Snapshot once at drag start; only existing runs are mated to anything. + const connectivity: RunMoveConnectivity | null = existedAtStart + ? startRunMoveConnectivity(node) + : null + const setPreview = (path: Vec3[]) => { previewPathRef.current = path setPreviewPath(path) @@ -174,7 +181,9 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { } prevSnapRef.current = cur hasMovedRef.current = true - setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)) + const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3) + setPreview(nextPath) + connectivity?.preview({ path: nextPath }) } const commit = (event: GridEvent) => { @@ -202,10 +211,21 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) selectId = created.id as AnyNodeId } else { - useScene.getState().updateNode(nodeId, { path: finalPath } as Partial) + // Fold connected-fitting / sibling-run follow-updates into the SAME + // batch as the moved run so the whole joint is one undo step. + const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? [] + useScene + .getState() + .updateNodes([ + { id: nodeId, data: { path: finalPath } as Partial }, + ...followUpdates, + ]) useScene.getState().markDirty(nodeId) } useScene.temporal.getState().pause() + // Followers are committed to the store — drop their live overrides so + // renderers read the canonical path/position. + connectivity?.clear() setMeshHidden(false) useAlignmentGuides.getState().clear() @@ -217,6 +237,7 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { } const onCancel = () => { + connectivity?.clear() if (existedAtStart) { setMeshHidden(false) useViewer.getState().setSelection({ selectedIds: [nodeId] }) @@ -236,6 +257,7 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { emitter.off('grid:move', onMove) emitter.off('grid:click', commit) emitter.off('tool:cancel', onCancel) + connectivity?.clear() useAlignmentGuides.getState().clear() if (existedAtStart) setMeshHidden(false) useScene.temporal.getState().resume() diff --git a/packages/nodes/src/pipe-segment/selection.tsx b/packages/nodes/src/pipe-segment/selection.tsx index 81f9e1f00..73ea952bc 100644 --- a/packages/nodes/src/pipe-segment/selection.tsx +++ b/packages/nodes/src/pipe-segment/selection.tsx @@ -15,13 +15,24 @@ import { import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' -import { useEffect, useRef, useState } from 'react' -import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' +import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + DoubleSide, + type Group, + type Object3D, + Plane, + Quaternion, + Raycaster, + Vector2, + Vector3, +} from 'three' import { collectScenePorts, DWV_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' -/** Handle pip radius (meters). */ -const HANDLE_RADIUS = 0.09 +/** Corner hex-disc radius (meters) — matches the duct corner handle. */ +const HANDLE_RADIUS = 0.11 +const HANDLE_COLOR = '#818cf8' +const HANDLE_HOVER_COLOR = '#a5b4fc' /** Port-snap radius for dragged run endpoints (meters, XZ). */ const PORT_SNAP_RADIUS_M = 0.4 @@ -294,35 +305,24 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj return ( - {pipe.path.map((p, i) => { - const active = draggingIndex === i - const hovered = hoverIndex === i - return ( - { - e.stopPropagation() - setHoverIndex(i) - if (draggingIndex === null) document.body.style.cursor = 'grab' - }} - onPointerLeave={() => { - setHoverIndex((prev) => (prev === i ? null : prev)) - if (draggingIndex === null) document.body.style.cursor = '' - }} - position={p as Point} - > - - - - ) - })} + {pipe.path.map((p, i) => ( + { + e.stopPropagation() + setHoverIndex(i) + if (draggingIndex === null) document.body.style.cursor = 'grab' + }} + onPointerLeave={() => { + setHoverIndex((prev) => (prev === i ? null : prev)) + if (draggingIndex === null) document.body.style.cursor = '' + }} + position={p as Point} + /> + ))} {draggingIndex !== null && pipe.path[draggingIndex] && (() => { @@ -359,4 +359,79 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj ) } +/** + * Billboarded hexagon disc handle for a pipe path vertex — the same visual + * the duct corner handle uses, so corner editing reads consistently across + * kinds. A flat `CircleGeometry` with 6 segments is the click target; an + * outer hex ring frames it. The group copies the camera's WORLD rotation + * (compensating for the rotated pipe/level parent) so the hex stays + * face-on at any viewing angle. + */ +function HexHandle({ + position, + active, + hovered, + onPointerDown, + onPointerEnter, + onPointerLeave, +}: { + position: Point + active: boolean + hovered: boolean + onPointerDown: (e: ThreeEvent) => void + onPointerEnter: (e: ThreeEvent) => void + onPointerLeave: () => void +}) { + const { camera } = useThree() + const groupRef = useRef(null) + const parentWorldQuat = useMemo(() => new Quaternion(), []) + const invParentWorldQuat = useMemo(() => new Quaternion(), []) + useFrame(() => { + const group = groupRef.current + if (!group) return + if (group.parent) { + group.parent.getWorldQuaternion(parentWorldQuat) + invParentWorldQuat.copy(parentWorldQuat).invert() + group.quaternion.copy(invParentWorldQuat).multiply(camera.quaternion) + } else { + group.quaternion.copy(camera.quaternion) + } + }) + + const color = active || hovered ? HANDLE_HOVER_COLOR : HANDLE_COLOR + const scale = hovered || active ? 1.25 : 1 + + return ( + + + + + + + + + + + ) +} + export default PipeSegmentSelectionAffordance diff --git a/packages/nodes/src/pipe-segment/tool.tsx b/packages/nodes/src/pipe-segment/tool.tsx index 077359125..e89dd75e0 100644 --- a/packages/nodes/src/pipe-segment/tool.tsx +++ b/packages/nodes/src/pipe-segment/tool.tsx @@ -55,6 +55,11 @@ import { pipeSegmentDefinition } from './definition' * - Esc clears an anchored start point. */ const PREVIEW_OPACITY = 0.55 +/** green-500 — the project's snap accent. The cursor ring + vertical line + * recolour to this while the point is snapped onto an existing run / port, + * so the coincidence reads with the familiar snap green (matches the duct + * tool). */ +const SNAP_CURSOR_COLOR = '#22c55e' /** Nominal residential DWV sizes (inches). */ const PIPE_DIAMETERS_IN = [1.25, 1.5, 2, 3, 4, 6] as const /** IPC default drain slope — ¼" per foot (1:48). */ @@ -478,6 +483,14 @@ const PipeSegmentTool = () => { triggerSFX('sfx:grid-snap') startPortRef.current = port startBodyRef.current = port ? null : body + // Continue an existing run at its true size: adopt the snapped + // pipe's diameter so the new segment carries on at the same gauge + // instead of whatever size the tool last drew. + const ownerId = port?.nodeId ?? (port ? null : body?.nodeId) + const owner = ownerId ? useScene.getState().nodes[ownerId] : null + if (owner?.type === 'pipe-segment' && owner.diameter !== diameterRef.current) { + setDiameter(owner.diameter) + } setDraftStart(point) return } @@ -612,7 +625,10 @@ const PipeSegmentTool = () => { dimension pill rides just above the cursor. */} {cursorPos && ( <> - + {pillParts && ( , + draggedIndex: number, + nodes: Record, +): DuctElbowEndpoint | null { + if (ductPath.length !== 2) return null + const elbowEnd = ductPath[draggedIndex === 0 ? 1 : 0]! + const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M + for (const node of Object.values(nodes)) { + if (!node || node.type !== 'duct-fitting') continue + const elbow = node as DuctFittingNode + if (elbow.fittingType !== 'elbow') continue + for (const port of getDuctFittingPorts(elbow)) { + if (port.id !== 'inlet' && port.id !== 'outlet') continue + if (distSq(port.position, elbowEnd) <= eps2) { + return { elbow, portId: port.id } + } + } + } + return null +} + +/** + * Plan the duct path + elbow re-aim for the dragged end at `draggedPoint`. + * The elbow swings its mated collar to face the junction→cursor direction; + * the duct runs from that collar to the cursor. Returns null when the + * required turn falls outside the elbow's buildable 15–90° range (caller + * keeps the plain free-drag for that frame). + */ +export function planDuctElbowEndpointReaim( + endpoint: DuctElbowEndpoint, + draggedIndex: number, + draggedPoint: Point, +): ElbowEndpointReaimPlan | null { + const { elbow, portId } = endpoint + const j = elbow.position + const away: Point = [draggedPoint[0] - j[0], draggedPoint[1] - j[1], draggedPoint[2] - j[2]] + if (away[0] * away[0] + away[1] * away[1] + away[2] * away[2] < 1e-10) return null + const realign = planElbowRealign(elbow, portId, away) + if (!realign) return null + const path: Point[] = + draggedIndex === 0 ? [draggedPoint, realign.collarPoint] : [realign.collarPoint, draggedPoint] + return { path, elbowUpdate: realign.update } +} diff --git a/packages/nodes/src/shared/path-point-affordance.ts b/packages/nodes/src/shared/path-point-affordance.ts index 74dbc5b6b..bac6ec7e1 100644 --- a/packages/nodes/src/shared/path-point-affordance.ts +++ b/packages/nodes/src/shared/path-point-affordance.ts @@ -1,10 +1,19 @@ import { + type AnyNode, type AnyNodeId, + analyzePortConnectivity, type FloorplanAffordance, type FloorplanAffordanceSession, + type PortConnectivity, + resolveConnectivityUpdates, useScene, } from '@pascal-app/core' import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' +import { + type DuctElbowEndpoint, + detectDuctElbowEndpoint, + planDuctElbowEndpointReaim, +} from './elbow-endpoint-reaim' /** * Shared "drag a path point" floor-plan affordance for polyline @@ -14,6 +23,16 @@ import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' * grid snap (Shift bypasses). The vertex's Y (elevation / slope) is held * fixed — plan editing never changes height. * + * Like the 3D handles, dragging a vertex that sits on a fitting carries the + * joint along (port connectivity): the fitting follows, connected runs stretch + * along their own axis and translate across it, and that perpendicular slide + * propagates down the chain. And — duct only — dragging the free end of a + * straight run whose other end sits on an elbow re-aims that elbow to follow + * the drag (bend angle adapts) instead of translating it rigidly. Holding + * **Alt** detaches: the joint breaks for the drag so the vertex moves on its + * own (no elbow re-aim, no connectivity follow). Behavioral parity with the + * 3D selection tool. + * * Wired via `def.floorplanAffordances['move-path-point']`; the floor-plan * builders emit `endpoint-handle` primitives carrying `{ pointIndex }` so * the dispatcher routes pointer-downs here. @@ -33,7 +52,7 @@ export function createPathPointMoveAffordance [...p] as [number, number, number]) const target = initialPath[pointIndex] @@ -41,18 +60,78 @@ export function createPathPointMoveAffordance c.nodeId) ?? []), + ] + + const followUpdates = (nextPath: [number, number, number][]) => { + if (!connectivity) return [] + const preview = { + ...(node as unknown as Record), + path: nextPath, + } as AnyNode + return resolveConnectivityUpdates(connectivity, preview).filter( + (u) => useScene.getState().nodes[u.id], + ) + } + return { - affectedIds: [node.id], + affectedIds, apply({ planPoint, modifiers }) { // Plan coords map x→world X, y→world Z. const raw: WallPlanPoint = [planPoint[0], planPoint[1]] const [sx, sz] = modifiers.shiftKey ? raw : snapPointToGrid(raw) - const nextPath = initialPath.map((p, i) => - i === pointIndex ? ([sx, y, sz] as [number, number, number]) : p, - ) - useScene - .getState() - .updateNodes([{ id: node.id, data: { path: nextPath } as Partial as never }]) + const dragged: [number, number, number] = [sx, y, sz] + // Alt = detach: break the joint for this drag — the elbow does NOT + // re-aim and mated fittings / runs do NOT follow; the vertex moves + // on its own. Mirrors the 3D selection drag and the wall corner. + const detached = modifiers.altKey + // Elbow re-aim: the elbow swings to follow the dragged end and the + // run rides its re-aimed collar. Out-of-range turns hold the frame. + if (!detached && elbowEndpoint) { + const plan = planDuctElbowEndpointReaim(elbowEndpoint, pointIndex, dragged) + if (!plan) return + useScene.getState().updateNodes([ + { id: node.id, data: { path: plan.path } as Partial as never }, + { + id: plan.elbowUpdate.id, + data: plan.elbowUpdate.data as Partial as never, + }, + ]) + return + } + const nextPath = initialPath.map((p, i) => (i === pointIndex ? dragged : p)) + useScene.getState().updateNodes([ + { id: node.id, data: { path: nextPath } as Partial as never }, + ...(detached ? [] : followUpdates(nextPath)).map((u) => ({ + id: u.id, + data: u.data as Partial as never, + })), + ]) }, canCommit() { const final = useScene.getState().nodes[node.id] as N | undefined diff --git a/packages/nodes/src/shared/port-connectivity-pipe.test.ts b/packages/nodes/src/shared/port-connectivity-pipe.test.ts index 5590832ee..3173e6b10 100644 --- a/packages/nodes/src/shared/port-connectivity-pipe.test.ts +++ b/packages/nodes/src/shared/port-connectivity-pipe.test.ts @@ -65,7 +65,7 @@ describe('port connectivity — DWV pipe family', () => { nodeRegistry._reset() }) - test('moving a pipe-fitting stretches the connected pipe-segment endpoint', () => { + test('moving a pipe-fitting carries the connected pipe-segment along', () => { // A sanitary tee at the origin; its run ports sit on ±X at the hub legs. const fitting = wasteTee() const outlet = portsOf('pipe-fitting', fitting as AnyNode).find((p) => p.id === 'outlet')! @@ -79,21 +79,20 @@ describe('port connectivity — DWV pipe family', () => { } const connectivity = analyzePortConnectivity(fitting as AnyNode, nodes) - // The run must be picked up as a stretchable endpoint partner. - const endpoint = connectivity.connections.find( - (c) => c.kind === 'duct-endpoint' && c.nodeId === run.id, - ) + // The run must be picked up as a carried partner. + const endpoint = connectivity.connections.find((c) => c.kind === 'run' && c.nodeId === run.id) expect(endpoint).toBeDefined() - // Move the fitting +1m in Z; the run's mated endpoint should follow. + // Move the fitting +1m in Z. That delta is PERPENDICULAR to the run's + // X-axis, so the whole run translates +Z (preserving direction, no skew). const moved = { ...(fitting as Record), position: [0, 0, 1] } as AnyNode const updates = resolveConnectivityUpdates(connectivity, moved) const runUpdate = updates.find((u) => u.id === run.id) expect(runUpdate).toBeDefined() const newPath = (runUpdate!.data as { path: [number, number, number][] }).path - // Tracked endpoint moved by the same +1m in Z; far end stayed put. + // Both endpoints rode +1m in Z; the run kept its length and direction. expect(newPath[0]![2]).toBeCloseTo(outlet.position[2] + 1, 6) - expect(newPath[1]![2]).toBeCloseTo(outlet.position[2], 6) + expect(newPath[1]![2]).toBeCloseTo(outlet.position[2] + 1, 6) }) test('incompatible systems do not fuse (a supply duct is not dragged by a waste fitting)', () => { diff --git a/packages/nodes/src/shared/run-move-connectivity.ts b/packages/nodes/src/shared/run-move-connectivity.ts new file mode 100644 index 000000000..f1824a29f --- /dev/null +++ b/packages/nodes/src/shared/run-move-connectivity.ts @@ -0,0 +1,99 @@ +import { + type AnyNode, + type AnyNodeId, + analyzePortConnectivity, + type PortConnectivity, + resolveConnectivityUpdates, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' + +type Vec3 = [number, number, number] + +/** Live transform of the moved node for a given drag frame — whichever of + * `path` (runs) or `position` (fittings) the node moves by. */ +type MovedTransform = { path?: Vec3[]; position?: Vec3 } + +/** + * Connectivity follow for whole-node ghost move tools (duct / pipe / + * lineset `MoveTool`, and the duct-fitting `MoveTool`). When you grab a + * committed run or fitting by its floating move button and slide it, the + * shared port-connectivity service walks the joint graph and produces the + * patches that keep neighbours welded: + * + * - Moving a **run**: both endpoints translate by the same delta, so any + * fitting mated to either end follows rigidly and the OTHER runs on those + * fittings stretch / translate per the axis-decomposition rules. + * - Moving a **fitting**: its collars push the connected runs — the part of + * the move along a run's axis stretches it, the part across translates the + * whole run (preserving its direction), and that perpendicular part carries + * on to whatever is mated to the run's far end. + * + * The moved node's own transform drives the snapshot. Followers preview + * through `useLiveNodeOverrides` (transient — no history churn; + * `getEffectiveNode` merges overrides so the connected geometry rebuilds at + * pointer rate), then fold into the commit's single tracked `updateNodes` + * batch. + * + * Returns `null` when nothing is connected, so callers skip all the work. + */ +export function startRunMoveConnectivity(node: AnyNode): RunMoveConnectivity | null { + const snapshot = analyzePortConnectivity(node, useScene.getState().nodes) + if (snapshot.connections.length === 0) return null + return new RunMoveConnectivity(node, snapshot) +} + +export class RunMoveConnectivity { + private overriddenIds: AnyNodeId[] = [] + + constructor( + private readonly node: AnyNode, + private readonly connectivity: PortConnectivity, + ) {} + + /** Patches that keep the connected nodes attached for a given live transform. */ + private updatesFor(transform: MovedTransform): { id: AnyNodeId; data: Partial }[] { + const preview = { ...(this.node as Record), ...transform } as AnyNode + return resolveConnectivityUpdates(this.connectivity, preview).filter( + (u) => useScene.getState().nodes[u.id], + ) + } + + /** Live-preview the followers for the moved node's current drag transform. */ + preview(transform: MovedTransform): void { + const updates = this.updatesFor(transform) + const overrides = useLiveNodeOverrides.getState() + const nextIds = updates.map((u) => u.id) + // Drop overrides on nodes that fell out of this frame's update set (e.g. a + // follower that returned to its origin resolves to a no-op delta). + for (const id of this.overriddenIds) { + if (!nextIds.includes(id)) { + overrides.clear(id) + if (useScene.getState().nodes[id]) useScene.getState().markDirty(id) + } + } + if (updates.length > 0) { + overrides.setMany(updates.map((u) => [u.id, u.data as Record] as const)) + for (const u of updates) { + if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id) + } + } + this.overriddenIds = nextIds + } + + /** Follower patches to fold into the commit `updateNodes` batch. */ + commitUpdates(transform: MovedTransform): { id: AnyNodeId; data: Partial }[] { + return this.updatesFor(transform) + } + + /** Drop all live overrides (commit clears them once the scene write lands; + * cancel / unmount clears them to reveal the unchanged followers). */ + clear(): void { + const overrides = useLiveNodeOverrides.getState() + for (const id of this.overriddenIds) { + overrides.clear(id) + if (useScene.getState().nodes[id]) useScene.getState().markDirty(id) + } + this.overriddenIds = [] + } +} From 37d5dfa9b99aeb90d22d41d4f4660599007bfbdd Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 18 Jun 2026 15:28:20 +0530 Subject: [PATCH 05/23] feat(mep): full DWV pipe parity for joint editing Bring pipe-segment endpoint drags and pipe-fitting moves to parity with duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow re-aim, and connectivity follow. Generalizes the shared elbow-reaim and auto-fitting helpers to dispatch by run kind so 2D and 3D share one path. Co-Authored-By: Claude Opus 4.7 --- packages/nodes/src/duct-segment/selection.tsx | 14 +- packages/nodes/src/pipe-fitting/definition.ts | 1 + packages/nodes/src/pipe-fitting/move-tool.tsx | 361 ++++++++++++++++++ packages/nodes/src/pipe-segment/selection.tsx | 229 ++++++----- packages/nodes/src/shared/auto-fitting.ts | 75 +++- .../nodes/src/shared/elbow-endpoint-reaim.ts | 89 +++-- .../nodes/src/shared/path-point-affordance.ts | 23 +- 7 files changed, 633 insertions(+), 159 deletions(-) create mode 100644 packages/nodes/src/pipe-fitting/move-tool.tsx diff --git a/packages/nodes/src/duct-segment/selection.tsx b/packages/nodes/src/duct-segment/selection.tsx index d6626c16b..01a56347d 100644 --- a/packages/nodes/src/duct-segment/selection.tsx +++ b/packages/nodes/src/duct-segment/selection.tsx @@ -28,9 +28,9 @@ import { Vector3, } from 'three' import { - type DuctElbowEndpoint, - detectDuctElbowEndpoint, - planDuctElbowEndpointReaim, + detectElbowEndpoint, + type ElbowEndpoint, + planElbowEndpointReaim, } from '../shared/elbow-endpoint-reaim' import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' @@ -130,7 +130,7 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj // Set when the run's OTHER end sits on an elbow collar: the elbow re-aims // to follow this drag instead of translating rigidly (mutually exclusive // with `connectivity`-driven follow for this endpoint). - elbowEndpoint: DuctElbowEndpoint | null + elbowEndpoint: ElbowEndpoint | null // True while Alt is held: the joint is detached for this drag, so the // final commit must omit elbow / connectivity updates. Tracked live so // `onUp` knows what the last frame did. @@ -185,7 +185,7 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj detached: boolean, ): { id: AnyNodeId; data: Partial }[] | null => { if (!detached && drag.elbowEndpoint) { - const plan = planDuctElbowEndpointReaim(drag.elbowEndpoint, drag.index, next) + const plan = planElbowEndpointReaim(drag.elbowEndpoint, drag.index, next) // Out of the elbow's buildable turn range — hold this frame. if (!plan) return null return [ @@ -239,8 +239,8 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj // collar fixed, bend angle adapts) — so the dragged end moves freely in // any direction instead of being locked to the segment's own axis, the // way a wall corner drags. Detected once against a drag-start snapshot. - const elbowEndpoint: DuctElbowEndpoint | null = isEndpoint - ? detectDuctElbowEndpoint(initialPath, index, useScene.getState().nodes) + const elbowEndpoint: ElbowEndpoint | null = isEndpoint + ? detectElbowEndpoint('duct-segment', initialPath, index, useScene.getState().nodes) : null const onMove = (event: PointerEvent) => { diff --git a/packages/nodes/src/pipe-fitting/definition.ts b/packages/nodes/src/pipe-fitting/definition.ts index 3c533cda8..917a41b75 100644 --- a/packages/nodes/src/pipe-fitting/definition.ts +++ b/packages/nodes/src/pipe-fitting/definition.ts @@ -78,6 +78,7 @@ export const pipeFittingDefinition: NodeDefinition = { // editor's SelectionAffordanceManager rather than `def.system`. affordanceTools: { selection: () => import('./selection'), + move: () => import('./move-tool'), }, tool: () => import('./tool'), diff --git a/packages/nodes/src/pipe-fitting/move-tool.tsx b/packages/nodes/src/pipe-fitting/move-tool.tsx new file mode 100644 index 000000000..902e139ff --- /dev/null +++ b/packages/nodes/src/pipe-fitting/move-tool.tsx @@ -0,0 +1,361 @@ +'use client' + +import { + type AlignmentAnchor, + type AnyNode, + type AnyNodeId, + emitter, + type GridEvent, + PipeFittingNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { + DragBoundingBox, + EDITOR_LAYER, + markToolCancelConsumed, + stripPlacementMetadataFlags, + triggerSFX, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useState } from 'react' +import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three' +import { + type Aabb2D, + collectGhostAlignmentCandidates, + resolveGhostAlignment, +} from '../shared/ghost-alignment' +import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity' +import { buildPipeFittingGeometry } from './geometry' + +type Vec3 = [number, number, number] + +const GHOST_COLOR = '#818cf8' +const GHOST_OPACITY = 0.5 + +/** Screen pixels → meters for the Ctrl-vertical (riser) drag — matches the + * pipe draw tool's Alt-vertical feel. 100 px ≈ 1 m. */ +const VERTICAL_PIXELS_PER_METER = 100 +const VERTICAL_Y_MIN_M = -3 +const VERTICAL_Y_MAX_M = 10 + +/** Snap a coordinate to the editor's live grid step. */ +function snapToGridStep(value: number): number { + const step = useEditor.getState().gridSnapStep + if (step <= 0) return value + return Math.round(value / step) * step +} + +/** World-space size + centre offset of `box` after the fitting's euler + * rotation — the footprint box that wraps the oriented geometry. */ +function rotatedBounds(box: Box3, rotation: Vec3): { size: Vec3; offset: Vec3 } { + const euler = new Euler(rotation[0], rotation[1], rotation[2]) + const min = box.min + const max = box.max + const corners: Vec3[] = [ + [min.x, min.y, min.z], + [max.x, min.y, min.z], + [min.x, max.y, min.z], + [min.x, min.y, max.z], + [max.x, max.y, min.z], + [max.x, min.y, max.z], + [min.x, max.y, max.z], + [max.x, max.y, max.z], + ] + const lo: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] + const hi: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY] + const v = new Vector3() + for (const c of corners) { + v.set(c[0], c[1], c[2]).applyEuler(euler) + lo[0] = Math.min(lo[0], v.x) + lo[1] = Math.min(lo[1], v.y) + lo[2] = Math.min(lo[2], v.z) + hi[0] = Math.max(hi[0], v.x) + hi[1] = Math.max(hi[1], v.y) + hi[2] = Math.max(hi[2], v.z) + } + return { + size: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]], + offset: [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2], + } +} + +/** + * Ghost-preview duplicate / move tool for DWV pipe fittings (elbow / wye / + * sanitary tee) — the plumbing sibling of the duct-fitting move tool. + * + * **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is + * inserted into the scene until the commit click. A translucent copy of the + * fitting (built from its real geometry, at its own `rotation`, so an elbow + * / riser stays properly aligned) rides the cursor inside a footprint + * bounding box — the same affordance other items get — and Figma-style + * alignment guides snap the box edges to nearby geometry. The commit click + * calls `createNode`; Esc discards. + * + * **Move** (existing fitting): the real node is hidden while the ghost + box + * track the cursor; commit writes the new `position` and reveals it. + * + * Modifiers (mirroring the duct-fitting move): + * - **Alt** detaches: the connected-pipe follow drops so the fitting moves + * on its own, leaving every mated run where it sits. + * - **Ctrl / Cmd** switches to vertical movement (stack / riser editing): XZ + * holds and the cursor's screen-Y drives the riser height. + * - **Shift** bypasses grid snapping / alignment. + * + * Wired via `def.affordanceTools.move`. + */ +export const MovePipeFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => { + const fitting = node as PipeFittingNode + const originalPosition = (fitting.position ?? [0, 0, 0]) as Vec3 + const rotation = (fitting.rotation ?? [0, 0, 0]) as Vec3 + const isNew = + typeof node.metadata === 'object' && + node.metadata !== null && + !Array.isArray(node.metadata) && + (node.metadata as Record).isNew === true + + const [cursorPos, setCursorPos] = useState(originalPosition) + + // Translucent stand-in built from the fitting's real geometry. Rotation is + // a geometry input (it decides the elbow's profile roles), so the ghost + // matches what lands. Rebuilt only if the source changes. + const ghost = useMemo(() => { + const group = buildPipeFittingGeometry(fitting) + group.traverse((obj) => { + const mesh = obj as Mesh + if ((mesh as { isMesh?: boolean }).isMesh) { + mesh.material = new MeshBasicMaterial({ + color: GHOST_COLOR, + transparent: true, + opacity: GHOST_OPACITY, + depthTest: false, + }) + mesh.renderOrder = 999 + } + obj.layers.set(EDITOR_LAYER) + }) + return group + }, [fitting]) + + // Footprint box that wraps the oriented geometry (size + centre offset), + // measured once from the ghost. + const bounds = useMemo(() => { + const box = new Box3().setFromObject(ghost) + if (box.isEmpty()) return { size: [0.3, 0.3, 0.3] as Vec3, offset: [0, 0, 0] as Vec3 } + return rotatedBounds(box, rotation) + }, [ghost, rotation]) + + useEffect(() => { + return () => { + ghost.traverse((obj) => { + const mesh = obj as Mesh + if ((mesh as { isMesh?: boolean }).isMesh) { + mesh.geometry?.dispose?.() + const mat = mesh.material as Material | Material[] + if (Array.isArray(mat)) for (const m of mat) m.dispose?.() + else mat?.dispose?.() + } + }) + } + }, [ghost]) + + useEffect(() => { + const nodeId = node.id as AnyNodeId + const [hx, , hz] = [bounds.size[0] / 2, 0, bounds.size[2] / 2] + const [ox, , oz] = bounds.offset + + useScene.temporal.getState().pause() + let committed = false + let hasMoved = false + const activatedAt = Date.now() + + const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates( + useScene.getState().nodes, + nodeId, + useViewer.getState().selection.levelId ?? node.parentId, + ) + + // Moving an existing fitting: hide its 3D MESH imperatively (NOT the + // store `visible` flag — the 2D floor plan skips `visible:false` nodes, + // so a store hide makes it vanish in 2D / split view). The ghost stands + // in until commit; the real mesh is restored on cancel / unmount. + const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId] + const setMeshHidden = (hidden: boolean) => { + const obj = sceneRegistry.nodes.get(nodeId) + if (obj) obj.visible = !hidden + } + if (existedAtStart) setMeshHidden(true) + + // Carry connected pipes as the fitting slides: the part of the move along + // a run's axis stretches it, the part across translates the whole run (and + // propagates to its far joint). Snapshot once at drag start; only existing + // fittings are mated to anything. + const connectivity: RunMoveConnectivity | null = existedAtStart + ? startRunMoveConnectivity(node) + : null + + let lastPos: Vec3 = originalPosition + // Tracks whether the last frame held Alt: the fitting is detached from its + // connected pipes for the drag, so they stay put (no follow) and the + // commit omits their updates. Mirrors the pipe endpoint's Alt-detach. + let lastDetached = false + // Anchor for the Ctrl-vertical (riser) drag: clientY + base Y captured the + // frame Ctrl is first held, so vertical mouse motion maps to Y. Cleared + // when Ctrl is released. Mirrors the draw tool's Alt-vertical anchor. + let verticalAnchor: { clientY: number; baseY: number } | null = null + + const onMove = (event: GridEvent) => { + const bypass = event.nativeEvent?.shiftKey === true + // Alt = detach: drop the connected-pipe follow so the fitting moves on + // its own, leaving every mated run where it sits. + const detached = event.nativeEvent?.altKey === true + // Ctrl/Cmd = vertical: XZ locks to where the fitting sits and the cursor's + // screen-Y drives the riser height (connected pipes still follow). + const vertical = event.nativeEvent?.ctrlKey === true || event.nativeEvent?.metaKey === true + const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY + const snap = bypass ? (v: number) => v : snapToGridStep + + let next: Vec3 + if (vertical && typeof clientY === 'number') { + if (!verticalAnchor) verticalAnchor = { clientY, baseY: lastPos[1] } + // Screen +Y points down, so subtract to map "drag up = raise". + const dy = (verticalAnchor.clientY - clientY) / VERTICAL_PIXELS_PER_METER + const y = Math.min( + VERTICAL_Y_MAX_M, + Math.max(VERTICAL_Y_MIN_M, verticalAnchor.baseY + snap(dy)), + ) + next = [lastPos[0], y, lastPos[2]] + useAlignmentGuides.getState().clear() + } else { + verticalAnchor = null + let x = snap(event.localPosition[0]) + let z = snap(event.localPosition[2]) + + // Alignment: snap the footprint box edges onto nearby geometry and + // publish guides (Alt / Shift bypass). + if (!bypass) { + const proposed: Aabb2D = { + minX: x + ox - hx, + maxX: x + ox + hx, + minZ: z + oz - hz, + maxZ: z + oz + hz, + } + const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates) + x += dx + z += dz + useAlignmentGuides.getState().set(guides) + } else { + useAlignmentGuides.getState().clear() + } + next = [x, lastPos[1], z] + } + + if (next[0] !== lastPos[0] || next[1] !== lastPos[1] || next[2] !== lastPos[2]) { + triggerSFX('sfx:grid-snap') + } + lastPos = next + lastDetached = detached + hasMoved = true + setCursorPos(next) + // Detached: keep the followers at their origin (drop any live overrides + // from a prior non-detached frame). Otherwise preview the follow. + if (detached) connectivity?.clear() + else connectivity?.preview({ position: next }) + } + + const commit = (event: GridEvent) => { + if (committed) return + if (Date.now() - activatedAt < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + if (!hasMoved) { + event.nativeEvent?.stopPropagation?.() + return + } + committed = true + + useScene.temporal.getState().resume() + let selectId = nodeId + if (isNew && !useScene.getState().nodes[nodeId]) { + const created = PipeFittingNode.parse({ + ...(node as Record), + position: lastPos, + metadata: stripPlacementMetadataFlags(node.metadata), + visible: true, + }) + useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) + selectId = created.id as AnyNodeId + } else { + // Fold connected-pipe / sibling-run follow-updates into the SAME batch + // as the moved fitting so the whole joint is one undo step. Detached + // (Alt on the final frame): the joint is broken, so nothing follows. + const followUpdates = lastDetached + ? [] + : (connectivity?.commitUpdates({ position: lastPos }) ?? []) + useScene + .getState() + .updateNodes([ + { id: nodeId, data: { position: lastPos } as Partial }, + ...followUpdates, + ]) + useScene.getState().markDirty(nodeId) + } + useScene.temporal.getState().pause() + // Followers are committed to the store — drop their live overrides so + // renderers read the canonical path/position. + connectivity?.clear() + setMeshHidden(false) + + useAlignmentGuides.getState().clear() + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [selectId] }) + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + connectivity?.clear() + if (existedAtStart) { + setMeshHidden(false) + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + } + useAlignmentGuides.getState().clear() + useScene.temporal.getState().resume() + markToolCancelConsumed() + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', commit) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', commit) + emitter.off('tool:cancel', onCancel) + connectivity?.clear() + useAlignmentGuides.getState().clear() + if (existedAtStart) setMeshHidden(false) + useScene.temporal.getState().resume() + } + }, [bounds, isNew, node, originalPosition]) + + return ( + + + + + ) +} + +export default MovePipeFittingTool diff --git a/packages/nodes/src/pipe-segment/selection.tsx b/packages/nodes/src/pipe-segment/selection.tsx index 73ea952bc..5d3f2be56 100644 --- a/packages/nodes/src/pipe-segment/selection.tsx +++ b/packages/nodes/src/pipe-segment/selection.tsx @@ -27,6 +27,11 @@ import { Vector2, Vector3, } from 'three' +import { + detectElbowEndpoint, + type ElbowEndpoint, + planElbowEndpointReaim, +} from '../shared/elbow-endpoint-reaim' import { collectScenePorts, DWV_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' /** Corner hex-disc radius (meters) — matches the duct corner handle. */ @@ -46,19 +51,36 @@ function snap(value: number, step: number): number { type Point = [number, number, number] /** - * Selection-time editing for committed DWV pipe runs: one draggable - * handle per path point. The plumbing sibling of the duct-segment - * affordance — same portal / constrained-drag / single-undo model, snapping - * to DWV ports instead of duct ports. + * Selection-time editing for committed DWV pipe runs: one draggable handle + * per path point. The plumbing sibling of the duct-segment affordance — + * same portal / free-drag / single-undo model, snapping to DWV ports + * instead of duct ports. * * Handles are PORTALED into the pipe's registered scene group so they * share its exact frame — path coords are node-local, and the level / * building transform above the group applies to the handles for free. + * Drag raycasts run in world space and convert hits back into the + * group's local frame before writing the path. + * + * Drag model: the point moves FREELY on the horizontal plane at its own + * height (no axis lock) — like a wall corner. Dragged run endpoints snap + * onto nearby typed DWV ports so a loose run can be mated onto a fitting + * after the fact. When the dragged endpoint belongs to a straight run whose + * OTHER end sits on an elbow collar, the elbow re-aims to follow the drag + * (junction + far collar fixed, bend angle adapts) instead of port-snapping. * - * Drag model: by default the point is CONSTRAINED to the axis the - * segment was drawn along. Holding **Alt** releases it into free - * horizontal-plane movement (endpoints port-snap onto nearby DWV ports). - * Holding **Shift** bypasses grid snapping for a precision drag. + * Modifiers (mirroring the duct corner drag): + * - **Alt** detaches: the joint breaks for this drag — the elbow does NOT + * re-aim and mated fittings / runs do NOT follow; the endpoint moves on its + * own (port re-mate still allowed so it can be reattached elsewhere). + * - **Cmd / Ctrl** switches to vertical movement (stack / riser editing): XZ + * holds and the cursor drives Y. + * - **Shift** bypasses grid snapping for a perfectly smooth precision drag. + * + * History does the single-undo dance: paused during the drag (the live + * `updateNode` ticks are untracked), then on release the path is + * reverted, history resumed, and the final path applied as one tracked + * change. */ const PipeSegmentSelectionAffordance = () => { const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -107,6 +129,14 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj // Connectivity snapshot taken at pointer-down: which fittings / pipes are // mated to this run's endpoints, so they follow as the endpoint moves. connectivity: PortConnectivity | null + // Set when the run's OTHER end sits on an elbow collar: the elbow re-aims + // to follow this drag instead of translating rigidly (mutually exclusive + // with `connectivity`-driven follow for this endpoint). + elbowEndpoint: ElbowEndpoint | null + // True while Alt is held: the joint is detached for this drag, so the + // final commit must omit elbow / connectivity updates. Tracked live so + // `onUp` knows what the last frame did. + detached: boolean } | null>(null) const makeRay = (clientX: number, clientY: number) => { @@ -126,24 +156,50 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj } /** - * Signed distance along `axisWorld` (unit, through `anchorWorld`) of the - * point on that line closest to the cursor ray. Null when the ray runs - * (near-)parallel to the axis and the projection is unstable. + * Local-frame Y where the cursor ray meets a vertical plane through + * `anchorWorld` that faces the camera — drives Cmd/Ctrl-vertical (riser) drag. + * Null when the ray is parallel to the plane. */ - const projectOntoAxis = ( + const intersectVerticalY = ( clientX: number, clientY: number, anchorWorld: Vector3, - axisWorld: Vector3, ): number | null => { - const ray = makeRay(clientX, clientY) - const w0 = new Vector3().subVectors(ray.origin, anchorWorld) - const b = ray.direction.dot(axisWorld) - const denom = 1 - b * b - if (Math.abs(denom) < 1e-6) return null - const d0 = ray.direction.dot(w0) - const e0 = axisWorld.dot(w0) - return (e0 - b * d0) / denom + // Plane normal: camera forward flattened onto the horizontal plane, so + // the plane stands upright through the point and faces the viewer. + const forward = camera.getWorldDirection(new Vector3()) + forward.y = 0 + if (forward.lengthSq() < 1e-6) forward.set(0, 0, 1) + forward.normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(forward, anchorWorld) + const hit = intersect(clientX, clientY, plane) + return hit ? toLocal(hit)[1] : null + } + + // Build the per-frame update batch for the dragged endpoint at `next`. + // Detached (Alt): only the pipe path moves — no elbow re-aim, no + // connectivity follow. Elbow mode: the run rides the elbow's re-aimed + // collar and the elbow swings to fit. Otherwise: the dragged point moves + // and any mated fittings / runs translate via connectivity. + const buildDragBatch = ( + drag: NonNullable, + next: Point, + detached: boolean, + ): { id: AnyNodeId; data: Partial }[] | null => { + if (!detached && drag.elbowEndpoint) { + const plan = planElbowEndpointReaim(drag.elbowEndpoint, drag.index, next) + // Out of the elbow's buildable turn range — hold this frame. + if (!plan) return null + return [ + { id: pipe.id as AnyNodeId, data: { path: plan.path } }, + { id: plan.elbowUpdate.id, data: plan.elbowUpdate.data as Partial }, + ] + } + const path = pipe.path.map((p, i) => (i === drag.index ? next : p)) as Point[] + return [ + { id: pipe.id as AnyNodeId, data: { path } }, + ...(detached ? [] : connectivityUpdatesForPath(drag.connectivity, path)), + ] } /** World-space position of a local path point. */ @@ -180,27 +236,14 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj const isEndpoint = index === 0 || index === initialPath.length - 1 - // Axis the segment was drawn along, at this point: from the - // neighbouring path point toward the dragged one. The default drag - // is constrained to this line. - const neighbor = initialPath[index === 0 ? 1 : index - 1]! - const axisLocal = new Vector3( - startPoint[0] - neighbor[0], - startPoint[1] - neighbor[1], - startPoint[2] - neighbor[2], - ) - if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0) - axisLocal.normalize() - // World-space anchor + axis, derived once — the constraint line is - // fixed for the whole drag regardless of where the point currently is. - const anchorWorldStart = toWorld(startPoint) - const axisWorld = toWorld([ - startPoint[0] + axisLocal.x, - startPoint[1] + axisLocal.y, - startPoint[2] + axisLocal.z, - ]) - .sub(anchorWorldStart) - .normalize() + // Elbow re-aim: if this is a straight run whose OTHER end sits on an + // elbow collar, the elbow swings to follow the drag (junction + far + // collar fixed, bend angle adapts) — so the dragged end moves freely in + // any direction instead of being locked to the segment's own axis, the + // way a wall corner drags. Detected once against a drag-start snapshot. + const elbowEndpoint: ElbowEndpoint | null = isEndpoint + ? detectElbowEndpoint('pipe-segment', initialPath, index, useScene.getState().nodes) + : null const onMove = (event: PointerEvent) => { const drag = dragRef.current @@ -209,16 +252,27 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj // Shift = precision: bypass grid snapping for a perfectly smooth // drag (snap() is a no-op at step 0). const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep + // Alt = detach: break the joint for this drag — the endpoint moves on + // its own, no elbow re-aim and no connectivity follow (it can still + // port-snap to re-mate elsewhere). Mirrors the wall corner drag. + const detached = event.altKey let next: Point | null = null - if (event.altKey) { - // Alt = freedom: slide on the horizontal plane at the point's - // height. Endpoints can port-snap here to mate onto a fitting. + if (event.metaKey || event.ctrlKey) { + // Cmd/Ctrl = vertical: keep XZ fixed and drive Y off the cursor + // against a vertical plane through the point (stack / riser editing). + const y = intersectVerticalY(event.clientX, event.clientY, toWorld(current)) + if (y !== null) next = [current[0], Math.max(0, snap(y, step)), current[2]] + } else { + // Default: free movement on the horizontal plane at the point's + // height (no axis lock). Endpoints can port-snap to mate a fitting. const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current)) const hit = intersect(event.clientX, event.clientY, plane) if (hit) { const local = toLocal(hit) next = [snap(local[0], step), current[1], snap(local[2], step)] - if (isEndpoint) { + // Port re-mate stays available whether detaching or free-dragging; + // it's only suppressed while the elbow is actively re-aiming. + if (isEndpoint && (detached || !drag.elbowEndpoint)) { const port = findNearestPortXZ( [local[0], current[1], local[2]], collectScenePorts({ excludeNodeId: pipe.id, systems: DWV_PORT_SYSTEMS }), @@ -227,30 +281,14 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj if (port) next = [port.position[0], port.position[1], port.position[2]] } } - } else { - // Default: constrained to the axis the segment was drawn along — - // slide the point closer / further along its own line. - const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld) - if (t !== null) { - const dist = snap(t, step) - next = [ - startPoint[0] + axisLocal.x * dist, - Math.max(0, startPoint[1] + axisLocal.y * dist), - startPoint[2] + axisLocal.z * dist, - ] - } } if (!next) return if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return + const batch = buildDragBatch(drag, next, detached) + if (!batch) return drag.current = next - const path = pipe.path.map((p, i) => (i === drag.index ? next! : p)) as Point[] - // Drag the run + any fittings mated to the moved endpoint as one batch. - useScene - .getState() - .updateNodes([ - { id: pipe.id as AnyNodeId, data: { path } }, - ...connectivityUpdatesForPath(drag.connectivity, path), - ]) + drag.detached = detached + useScene.getState().updateNodes(batch) } const onUp = () => { @@ -259,19 +297,32 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj drag.cleanup() dragRef.current = null setDraggingIndex(null) - // Single-undo dance: revert (still paused), resume, re-apply the - // final path — plus any connected fitting moves — as one tracked batch. - const finalPath = drag.initialPath.map((p, i) => - i === drag.index ? drag.current : p, - ) as Point[] - const finalUpdates = connectivityUpdatesForPath(drag.connectivity, finalPath) - // Revert the run AND the followers to their pre-drag state while paused - // so history captures a clean before→after delta. - const revertUpdates = (drag.connectivity?.connections ?? []).flatMap((conn) => - conn.kind === 'rigid-node' - ? [{ id: conn.nodeId, data: { position: conn.startPosition } as Partial }] - : [{ id: conn.nodeId, data: { path: conn.startPath } as Partial }], - ) + // Single-undo dance: revert (still paused), resume, re-apply the final + // batch as one tracked change. The final batch is built the same way as + // each live frame (elbow re-aim, rigid connectivity follow, or — when + // detached — just the pipe path). + const detached = drag.detached + const finalBatch = buildDragBatch(drag, drag.current, detached) + // Revert the run AND whatever the drag carried to their pre-drag state + // while paused so history captures a clean before→after delta. When + // detached nothing else moved, so only the run needs reverting. + const revertUpdates: { id: AnyNodeId; data: Partial }[] = detached + ? [] + : drag.elbowEndpoint + ? [ + { + id: drag.elbowEndpoint.elbow.id as AnyNodeId, + data: { + angle: drag.elbowEndpoint.elbow.angle, + rotation: drag.elbowEndpoint.elbow.rotation, + } as Partial, + }, + ] + : (drag.connectivity?.connections ?? []).map((conn) => + conn.kind === 'rigid-node' + ? { id: conn.nodeId, data: { position: conn.startPosition } as Partial } + : { id: conn.nodeId, data: { path: conn.startPath } as Partial }, + ) useScene .getState() .updateNodes([ @@ -279,13 +330,9 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj ...revertUpdates.filter((u) => useScene.getState().nodes[u.id]), ]) resumeSceneHistory(useScene) - const moved = finalPath[drag.index]!.some( - (v, axis) => v !== drag.initialPath[drag.index]![axis], - ) - if (moved) { - useScene - .getState() - .updateNodes([{ id: pipe.id as AnyNodeId, data: { path: finalPath } }, ...finalUpdates]) + const moved = drag.current.some((v, axis) => v !== drag.initialPath[drag.index]![axis]) + if (moved && finalBatch) { + useScene.getState().updateNodes(finalBatch) } } @@ -297,7 +344,15 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj document.body.style.cursor = '' } - dragRef.current = { index, initialPath, current: startPoint, cleanup, connectivity } + dragRef.current = { + index, + initialPath, + current: startPoint, + cleanup, + connectivity, + elbowEndpoint, + detached: false, + } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onUp) diff --git a/packages/nodes/src/shared/auto-fitting.ts b/packages/nodes/src/shared/auto-fitting.ts index 6d14c3513..71cd57299 100644 --- a/packages/nodes/src/shared/auto-fitting.ts +++ b/packages/nodes/src/shared/auto-fitting.ts @@ -462,24 +462,30 @@ export type ElbowRealignPlan = { collarPoint: Point } +export type PipeElbowRealignPlan = { + update: { id: PipeFittingNode['id']; data: { angle: number; rotation: Point } } + collarPoint: Point +} + /** - * Re-aim an existing elbow whose open collar a new run just snapped - * onto. The junction stays put and the OTHER collar keeps its exact - * position + direction (it's mated to something), while the snapped - * collar swings to face the incoming run — the elbow's `angle` adjusts - * to whatever turn that requires. + * Shared elbow re-aim geometry for duct AND pipe elbows — both share the + * exact same local convention (inlet -X, outlet turned `angle`° in XZ, + * 15–90° buildable range), so only the collar leg length differs. * - * Geometry: with the fixed collar's outward direction f and the desired - * free direction `awayDir`, the elbow's local inlet/outlet pair subtends - * 180° − angle, so the new turn is θ = 180° − ∠(f, away). Buildable only - * while θ stays in the elbow's 15–90° range — otherwise null and the - * caller leaves the joint as a plain butt joint. + * The junction stays put and the OTHER collar keeps its exact position + + * direction (it's mated to something), while the snapped collar swings to + * face `awayDir` — the elbow's `angle` adjusts to whatever turn that + * requires. Geometry: with the fixed collar's outward direction f and the + * desired free direction `awayDir`, the elbow's local inlet/outlet pair + * subtends 180° − angle, so the new turn is θ = 180° − ∠(f, away). + * Buildable only while θ stays in 15–90° — otherwise null. */ -export function planElbowRealign( - elbow: DuctFittingNode, +function planElbowRealignCore( + elbow: { fittingType: string; rotation: Point; angle: number; position: Point }, snappedPortId: string, awayDir: Point, -): ElbowRealignPlan | null { + leg: number, +): { angle: number; rotation: Point; collarPoint: Point } | null { if (elbow.fittingType !== 'elbow') return null if (snappedPortId !== 'inlet' && snappedPortId !== 'outlet') return null @@ -518,21 +524,48 @@ export function planElbowRealign( ) const euler = new Euler().setFromQuaternion(rotation) - const leg = fittingLegLength(elbow.diameter) const collar = new Vector3(...elbow.position).addScaledVector(away, leg) return { - update: { - id: elbow.id, - data: { - angle: Math.min(90, (turnNew * 180) / Math.PI), - rotation: [euler.x, euler.y, euler.z], - }, - }, + angle: Math.min(90, (turnNew * 180) / Math.PI), + rotation: [euler.x, euler.y, euler.z], collarPoint: [collar.x, collar.y, collar.z], } } +/** Re-aim a DUCT elbow whose open collar a new run just snapped onto. */ +export function planElbowRealign( + elbow: DuctFittingNode, + snappedPortId: string, + awayDir: Point, +): ElbowRealignPlan | null { + const core = planElbowRealignCore(elbow, snappedPortId, awayDir, fittingLegLength(elbow.diameter)) + if (!core) return null + return { + update: { id: elbow.id, data: { angle: core.angle, rotation: core.rotation } }, + collarPoint: core.collarPoint, + } +} + +/** Re-aim a DWV PIPE elbow — same geometry, pipe collar leg length. */ +export function planPipeElbowRealign( + elbow: PipeFittingNode, + snappedPortId: string, + awayDir: Point, +): PipeElbowRealignPlan | null { + const core = planElbowRealignCore( + elbow, + snappedPortId, + awayDir, + pipeFittingLegLength(elbow.diameter), + ) + if (!core) return null + return { + update: { id: elbow.id, data: { angle: core.angle, rotation: core.rotation } }, + collarPoint: core.collarPoint, + } +} + // ─── DWV pipe joints ───────────────────────────────────────────────── export type PipeElbowPlan = { diff --git a/packages/nodes/src/shared/elbow-endpoint-reaim.ts b/packages/nodes/src/shared/elbow-endpoint-reaim.ts index 39ca19186..773143230 100644 --- a/packages/nodes/src/shared/elbow-endpoint-reaim.ts +++ b/packages/nodes/src/shared/elbow-endpoint-reaim.ts @@ -1,46 +1,60 @@ -import type { AnyNode, AnyNodeId, DuctFittingNode } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, DuctFittingNode, PipeFittingNode } from '@pascal-app/core' import { getDuctFittingPorts } from '../duct-fitting/ports' -import { planElbowRealign } from './auto-fitting' +import { getPipeFittingPorts } from '../pipe-fitting/ports' +import { planElbowRealign, planPipeElbowRealign } from './auto-fitting' /** - * Shared "drag a duct end, the connected elbow re-aims" logic for the - * selection-time endpoint drag (3D `selection.tsx` and its 2D - * `move-path-point` twin). + * Shared "drag a run end, the connected elbow re-aims" logic for the + * selection-time endpoint drag — duct (`duct-segment`) and DWV pipe + * (`pipe-segment`) alike, plus their 2D `move-path-point` twins. * * The motivating behaviour (mirrors how a wall corner drags): when you grab - * the free end of a straight duct whose OTHER end sits on an elbow collar, + * the free end of a straight run whose OTHER end sits on an elbow collar, * the elbow's junction and its far (mated) collar stay put while the near * collar swings to face the dragged end — the bend `angle` adjusts to fit. - * The duct then runs from the re-aimed collar to wherever you drag, in ANY + * The run then goes from the re-aimed collar to wherever you drag, in ANY * direction, instead of being locked to the segment's original axis. * - * Detection is done ONCE at drag start (`detectDuctElbowEndpoint`) against a - * snapshot of the elbow; the per-frame plan (`planDuctElbowEndpointReaim`) + * Detection is done ONCE at drag start (`detectElbowEndpoint`) against a + * snapshot of the elbow; the per-frame plan (`planElbowEndpointReaim`) * always re-derives from that original snapshot, so live mutation of the * elbow's angle/rotation never compounds. */ type Point = [number, number, number] -/** Distance (m) under which a duct end counts as sitting on an elbow collar — +/** Distance (m) under which a run end counts as sitting on an elbow collar — * matches core's port-coincidence epsilon. */ const COINCIDENT_EPS_M = 0.05 -export type DuctElbowEndpoint = { +/** Which run kind we're editing decides which fitting kind to look for. */ +type ElbowFitting = DuctFittingNode | PipeFittingNode + +export type ElbowEndpoint = { /** The elbow node as it stood at drag start (the stable reference). */ - elbow: DuctFittingNode - /** Which elbow collar the duct's non-dragged end is mated to. */ + elbow: ElbowFitting + /** Which elbow collar the run's non-dragged end is mated to. */ portId: 'inlet' | 'outlet' + /** The fitting kind, so the per-frame plan calls the right realign. */ + fittingType: 'duct-fitting' | 'pipe-fitting' } export type ElbowEndpointReaimPlan = { - /** New path for the dragged duct: the dragged end at the cursor, the + /** New path for the dragged run: the dragged end at the cursor, the * elbow end pulled onto the re-aimed collar. */ path: Point[] /** Patch re-aiming the elbow (new turn angle + orientation). */ elbowUpdate: { id: AnyNodeId; data: { angle: number; rotation: Point } } } +/** A run kind ('duct-segment' / 'pipe-segment') → the elbow fitting kind it + * mates to. Anything else has no elbow re-aim. */ +function fittingTypeForRun(runKind: string): 'duct-fitting' | 'pipe-fitting' | null { + if (runKind === 'duct-segment') return 'duct-fitting' + if (runKind === 'pipe-segment') return 'pipe-fitting' + return null +} + function distSq(a: Point | readonly number[], b: Point | readonly number[]): number { const dx = a[0]! - b[0]! const dy = a[1]! - b[1]! @@ -49,26 +63,34 @@ function distSq(a: Point | readonly number[], b: Point | readonly number[]): num } /** - * If `ductPath` is a straight two-point run whose NON-dragged end sits on an - * elbow's inlet/outlet collar, return that elbow snapshot + the mated port id. - * Otherwise null — the caller falls back to plain free-drag. + * If `runPath` is a straight two-point run whose NON-dragged end sits on an + * elbow's inlet/outlet collar, return that elbow snapshot + the mated port + * id. `runKind` selects which fitting kind to scan for. Otherwise null — + * the caller falls back to plain free-drag. */ -export function detectDuctElbowEndpoint( - ductPath: ReadonlyArray, +export function detectElbowEndpoint( + runKind: string, + runPath: ReadonlyArray, draggedIndex: number, nodes: Record, -): DuctElbowEndpoint | null { - if (ductPath.length !== 2) return null - const elbowEnd = ductPath[draggedIndex === 0 ? 1 : 0]! +): ElbowEndpoint | null { + if (runPath.length !== 2) return null + const fittingType = fittingTypeForRun(runKind) + if (!fittingType) return null + const elbowEnd = runPath[draggedIndex === 0 ? 1 : 0]! const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M for (const node of Object.values(nodes)) { - if (!node || node.type !== 'duct-fitting') continue - const elbow = node as DuctFittingNode + if (!node || node.type !== fittingType) continue + const elbow = node as ElbowFitting if (elbow.fittingType !== 'elbow') continue - for (const port of getDuctFittingPorts(elbow)) { + const ports = + fittingType === 'duct-fitting' + ? getDuctFittingPorts(elbow as DuctFittingNode) + : getPipeFittingPorts(elbow as PipeFittingNode) + for (const port of ports) { if (port.id !== 'inlet' && port.id !== 'outlet') continue if (distSq(port.position, elbowEnd) <= eps2) { - return { elbow, portId: port.id } + return { elbow, portId: port.id, fittingType } } } } @@ -76,22 +98,25 @@ export function detectDuctElbowEndpoint( } /** - * Plan the duct path + elbow re-aim for the dragged end at `draggedPoint`. + * Plan the run path + elbow re-aim for the dragged end at `draggedPoint`. * The elbow swings its mated collar to face the junction→cursor direction; - * the duct runs from that collar to the cursor. Returns null when the + * the run goes from that collar to the cursor. Returns null when the * required turn falls outside the elbow's buildable 15–90° range (caller * keeps the plain free-drag for that frame). */ -export function planDuctElbowEndpointReaim( - endpoint: DuctElbowEndpoint, +export function planElbowEndpointReaim( + endpoint: ElbowEndpoint, draggedIndex: number, draggedPoint: Point, ): ElbowEndpointReaimPlan | null { - const { elbow, portId } = endpoint + const { elbow, portId, fittingType } = endpoint const j = elbow.position const away: Point = [draggedPoint[0] - j[0], draggedPoint[1] - j[1], draggedPoint[2] - j[2]] if (away[0] * away[0] + away[1] * away[1] + away[2] * away[2] < 1e-10) return null - const realign = planElbowRealign(elbow, portId, away) + const realign = + fittingType === 'duct-fitting' + ? planElbowRealign(elbow as DuctFittingNode, portId, away) + : planPipeElbowRealign(elbow as PipeFittingNode, portId, away) if (!realign) return null const path: Point[] = draggedIndex === 0 ? [draggedPoint, realign.collarPoint] : [realign.collarPoint, draggedPoint] diff --git a/packages/nodes/src/shared/path-point-affordance.ts b/packages/nodes/src/shared/path-point-affordance.ts index bac6ec7e1..0a055ec94 100644 --- a/packages/nodes/src/shared/path-point-affordance.ts +++ b/packages/nodes/src/shared/path-point-affordance.ts @@ -10,9 +10,9 @@ import { } from '@pascal-app/core' import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' import { - type DuctElbowEndpoint, - detectDuctElbowEndpoint, - planDuctElbowEndpointReaim, + detectElbowEndpoint, + type ElbowEndpoint, + planElbowEndpointReaim, } from './elbow-endpoint-reaim' /** @@ -26,9 +26,9 @@ import { * Like the 3D handles, dragging a vertex that sits on a fitting carries the * joint along (port connectivity): the fitting follows, connected runs stretch * along their own axis and translate across it, and that perpendicular slide - * propagates down the chain. And — duct only — dragging the free end of a - * straight run whose other end sits on an elbow re-aims that elbow to follow - * the drag (bend angle adapts) instead of translating it rigidly. Holding + * propagates down the chain. And — duct / pipe only — dragging the free end + * of a straight run whose other end sits on an elbow re-aims that elbow to + * follow the drag (bend angle adapts) instead of translating it rigidly. Holding * **Alt** detaches: the joint breaks for the drag so the vertex moves on its * own (no elbow re-aim, no connectivity follow). Behavioral parity with the * 3D selection tool. @@ -65,15 +65,14 @@ export function createPathPointMoveAffordance as never }, From 810f90c81de2a6c7044d4c65cfa7135e33576af6 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 19 Jun 2026 01:57:18 +0530 Subject: [PATCH 06/23] feat(mep): wall-style arrow handles for duct fittings + segments Add violet directional arrow affordances to duct-fitting selection (height, move cross, rotate arc) mirroring the duct-segment rig: portaled into the parent frame to stay out of the selection outline, rendered via the shared HandleArrow, and carrying mated-run connectivity through the single-undo dance. The move cross engages press-drag-release (placementDragMode) the same way the floating drag does, so the markup hit-areas go inert and the fitting move tool commits on pointer-up. Also re-export the HandleArrow primitives from @pascal-app/editor and extend the duct-segment side-move/floorplan affordances. Co-Authored-By: Claude Opus 4.7 --- .../components/editor/node-arrow-handles.tsx | 5 + packages/editor/src/index.tsx | 20 + packages/nodes/src/duct-fitting/move-tool.tsx | 32 +- packages/nodes/src/duct-fitting/selection.tsx | 364 +++++++++++++++++- packages/nodes/src/duct-segment/definition.ts | 4 + packages/nodes/src/duct-segment/floorplan.ts | 30 ++ packages/nodes/src/duct-segment/selection.tsx | 317 ++++++++++++++- .../src/shared/path-segment-affordance.ts | 134 +++++++ 8 files changed, 884 insertions(+), 22 deletions(-) create mode 100644 packages/nodes/src/shared/path-segment-affordance.ts diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 515dc0480..c9435c55a 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -109,11 +109,16 @@ export { ARROW_COLOR, ARROW_HOVER_COLOR, ARROW_SCALE, + createArrowHandleGeometry, createArrowHitAreaGeometry, createEndpointHitAreaGeometry, createMoveCrossHandleGeometry, createRotateArrowHandleGeometry, createRotateArrowHitAreaGeometry, + HandleArrow, + type HandleArrowInputShape, + type HandleArrowPlacement, + type HandleArrowProps, HIT_AREA_MARGIN, InvisibleHandleHitArea, NO_RAYCAST, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index b9833369a..aab874d4f 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -18,6 +18,26 @@ export { formatMeasurement, MeasurementPill, } from './components/editor/measurement-pill' +// In-world arrow handle primitives (chevron geometry, invisible hit area, +// shared material, palette + scale constants). Re-exported so kind-owned +// 3D selection affordances in `@pascal-app/nodes` (duct side-move / height / +// extend arrows) reuse the same UI family as the wall / fence side handles. +export { + ARROW_COLOR, + ARROW_HOVER_COLOR, + ARROW_SCALE, + createArrowHandleGeometry, + createArrowHitAreaGeometry, + HandleArrow, + type HandleArrowInputShape, + type HandleArrowPlacement, + type HandleArrowProps, + InvisibleHandleHitArea, + NO_RAYCAST, + swallowNextClick, + useArrowMaterial, + useInvisibleHitAreaMaterial, +} from './components/editor/node-arrow-handles' export { type SnapshotCameraData, ThumbnailGenerator, diff --git a/packages/nodes/src/duct-fitting/move-tool.tsx b/packages/nodes/src/duct-fitting/move-tool.tsx index 9206e64d1..bc2950413 100644 --- a/packages/nodes/src/duct-fitting/move-tool.tsx +++ b/packages/nodes/src/duct-fitting/move-tool.tsx @@ -11,6 +11,7 @@ import { useScene, } from '@pascal-app/core' import { + consumePlacementDragRelease, DragBoundingBox, EDITOR_LAYER, markToolCancelConsumed, @@ -258,9 +259,12 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => { else connectivity?.preview({ position: next }) } - const commit = (event: GridEvent) => { + const commit = (event: GridEvent, fromDragRelease = false) => { if (committed) return - if (Date.now() - activatedAt < 150) { + // The 150ms debounce only guards click-to-place against the arming click + // double-firing; a press-drag release is a distinct pointerup gesture, so + // it skips the guard (a quick drag-flick still commits). + if (!fromDragRelease && Date.now() - activatedAt < 150) { event.nativeEvent?.stopPropagation?.() return } @@ -323,14 +327,38 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => { useEditor.getState().setMovingNode(null) } + // Press-drag-release: when the move was engaged by the drag gesture (the + // selection rig's move cross or a future floating drag), `placementDragMode` + // is set, so commit on pointer-up at the last previewed position instead of + // waiting for a second click — same contract as every other move tool. + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + // A press-release that never moved isn't a placement — back out cleanly + // (drop the ghost, re-select the fitting) instead of leaving the tool + // armed waiting for a click. + if (!hasMoved) { + onCancel() + return + } + commit( + { + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as GridEvent, + true, + ) + } + emitter.on('grid:move', onMove) emitter.on('grid:click', commit) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('grid:move', onMove) emitter.off('grid:click', commit) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) connectivity?.clear() useAlignmentGuides.getState().clear() if (existedAtStart) setMeshHidden(false) diff --git a/packages/nodes/src/duct-fitting/selection.tsx b/packages/nodes/src/duct-fitting/selection.tsx index 8b396b678..a60b8d55f 100644 --- a/packages/nodes/src/duct-fitting/selection.tsx +++ b/packages/nodes/src/duct-fitting/selection.tsx @@ -1,27 +1,99 @@ 'use client' -import { type AnyNodeId, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + analyzePortConnectivity, + type Cursor, + type DuctFittingNode, + type PortConnectivity, + pauseSceneHistory, + resolveConnectivityUpdates, + resumeSceneHistory, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { ARROW_SCALE, HandleArrow, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect } from 'react' -import { cycleRotationAxis } from '../shared/fitting-rotation' +import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useState } from 'react' +import { + Euler, + type Group, + type Object3D, + OrthographicCamera, + Plane, + Quaternion, + Raycaster, + Vector2, + Vector3, +} from 'three' +import { + AXIS_VECTORS, + cycleRotationAxis, + getRotationAxis, + type RotationAxis, +} from '../shared/fitting-rotation' +import { fittingLegLength } from './ports' + +type Point = [number, number, number] + +/** Stand-off (meters) from the fitting body to each arrow. */ +const ARROW_GAP = 0.3 + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +/** Rough body radius (meters) — the larger of the fitting's two collar reaches, + * used to stand the arrows clear of the geometry. */ +function fittingExtentM(node: DuctFittingNode): number { + const d2 = (node as { diameter2?: number }).diameter2 ?? node.diameter + return Math.max(fittingLegLength(node.diameter), fittingLegLength(d2)) +} + +/** The transform a drag frame writes onto the fitting. */ +type FittingTransform = { position?: Point; rotation?: Point } /** - * Selection-time rotation support for placed fittings, mounted by the - * editor's SelectionAffordanceManager (`def.affordanceTools.selection`). - * The R/T rotation itself lives in `def.keyboardActions` (the editor's - * keyboard hook dispatches it); this contributes the piece that hook - * can't: **Alt cycles the active rotation axis** while a single fitting - * is selected. The axis lives on `useEditor.rotationAxis`, which the - * floating action menu reads to show the axis pill above the selected - * fitting — so this component renders nothing. + * Selection-time affordances for a placed duct fitting — the 3D twin of the + * wall side handles, mirroring the duct-segment selection rig: + * + * - **Height** (upright chevron above the body): raise / lower the fitting on + * a camera-facing vertical plane (riser editing). Connected runs follow. + * - **Move** (ground cross): hands off to `MoveDuctFittingTool` — the same + * click-to-place ghost move the floating Move button engages, with its own + * alignment guides, Ctrl-vertical, and Alt-detach. + * - **Rotate** (curved arrow): spin the fitting about the active rotation axis + * (Alt cycles it; R / T step it). Connected runs re-aim via port follow. + * + * The handle rig is PORTALED into the fitting group's PARENT — never the + * fitting group itself — because the selection outliner (`MergedOutlineNode`) + * traces every descendant mesh of the SELECTED node, so a hit-area cylinder + * parented under the fitting would be swept into its selection outline (the + * stray circle around the arrows). Walls / doors / windows dodge it the same + * way. The fitting's local `position` is expressed in the parent's frame, so + * an identity group under the parent lets us place arrows at absolute + * level-local coords with world-aligned axes (height = world up, rotate on the + * world horizontal plane). + * + * History does the single-undo dance: paused during the drag (live ticks are + * untracked), reverted on release, resumed, then the final transform re-applied + * as one tracked change so the whole joint is one undo step. */ const DuctFittingSelectionAffordance = () => { const selectedIds = useViewer((s) => s.selection.selectedIds) - const hasSelectedFitting = useScene((s) => { - if (selectedIds.length !== 1) return false - return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'duct-fitting' + const fitting = useScene((s) => { + if (selectedIds.length !== 1) return null + const node = s.nodes[selectedIds[0] as AnyNodeId] + return node?.type === 'duct-fitting' ? (node as DuctFittingNode) : null }) + // Alt cycles the active rotation axis while a single fitting is selected — + // the piece `def.keyboardActions` (R / T rotate) can't contribute. The pill + // above the fitting reads `useEditor.rotationAxis` to show it. + const hasSelectedFitting = !!fitting useEffect(() => { if (!hasSelectedFitting) return const onKeyDown = (e: KeyboardEvent) => { @@ -31,13 +103,271 @@ const DuctFittingSelectionAffordance = () => { e.preventDefault() cycleRotationAxis() } - // Bubble phase — when the placement tool is active its capture-phase - // handler stops propagation, so the two never double-cycle. window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [hasSelectedFitting]) - return null + // Portal target: the fitting's registered group. Resolved with a rAF retry + // because registration lands on the renderer's mount, a frame after select. + const fittingId = fitting?.id ?? null + const [target, setTarget] = useState(null) + useEffect(() => { + if (!fittingId) { + setTarget(null) + return + } + let frameId = 0 + const resolve = () => { + const next = sceneRegistry.nodes.get(fittingId as AnyNodeId) ?? null + setTarget((cur) => (cur === next ? cur : next)) + if (!next) frameId = window.requestAnimationFrame(resolve) + } + resolve() + return () => window.cancelAnimationFrame(frameId) + }, [fittingId]) + + if (!fitting || !target) return null + const mount = target.parent ?? target + return createPortal(, mount, undefined) +} + +const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: Object3D }) => { + const { camera, gl } = useThree() + const [frame, setFrame] = useState(null) + const [hover, setHover] = useState<'height' | 'move' | 'rotate' | null>(null) + // True while a height / rotate drag is live — the arrows hide (the window + // pointer handlers own the gesture), exactly like the wall side handles. + const [dragging, setDragging] = useState(false) + + const makeRay = (clientX: number, clientY: number) => { + const rect = gl.domElement.getBoundingClientRect() + const ndc = new Vector2( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ) + const raycaster = new Raycaster() + raycaster.setFromCamera(ndc, camera) + return raycaster.ray + } + const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => { + const hit = new Vector3() + return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null + } + /** World hit on a vertical, camera-facing plane through `anchorWorld`, + * returned as a level-local Y (the frame is axis-aligned to the parent). */ + const intersectVerticalY = ( + clientX: number, + clientY: number, + anchorWorld: Vector3, + ): number | null => { + if (!frame) return null + const forward = camera.getWorldDirection(new Vector3()) + forward.y = 0 + if (forward.lengthSq() < 1e-6) forward.set(0, 0, 1) + forward.normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(forward, anchorWorld) + const hit = intersect(clientX, clientY, plane) + return hit ? frame.worldToLocal(hit.clone()).y : null + } + + const toWorld = (p: Point): Vector3 => + frame ? frame.localToWorld(new Vector3(p[0], p[1], p[2])) : new Vector3(p[0], p[1], p[2]) + + // Follow-updates for runs / fittings mated to this fitting, given a preview + // transform. Endpoints whose ports didn't move resolve to a zero delta. + const connectivityUpdates = ( + connectivity: PortConnectivity | null, + transform: FittingTransform, + ): { id: AnyNodeId; data: Partial }[] => { + if (!connectivity) return [] + const preview = { ...(fitting as Record), ...transform } as AnyNode + return resolveConnectivityUpdates(connectivity, preview).filter( + (u) => useScene.getState().nodes[u.id], + ) + } + + /** + * Shared lifecycle for the height / rotate arrow drags. `makeCompute` is + * built at pointer-down so it can capture the grab anchor (the cursor's + * start Y / bearing) and avoid a teleport. Each frame `compute` turns the + * cursor into the fitting's next transform; the fitting writes it and any + * mated runs follow via port connectivity. + */ + const beginDrag = + ( + cursor: Cursor, + makeCompute: ( + e: ThreeEvent, + ) => (event: PointerEvent) => FittingTransform | null, + ) => + (e: ThreeEvent) => { + e.stopPropagation() + const initialPosition = [...fitting.position] as Point + const initialRotation = [...fitting.rotation] as Point + const connectivity = analyzePortConnectivity(fitting as AnyNode, useScene.getState().nodes) + const compute = makeCompute(e) + pauseSceneHistory(useScene) + useViewer.getState().setInputDragging(true) + setDragging(true) + document.body.style.cursor = cursor + let current: FittingTransform | null = null + + const buildBatch = (t: FittingTransform): { id: AnyNodeId; data: Partial }[] => [ + { id: fitting.id as AnyNodeId, data: t as Partial }, + ...connectivityUpdates(connectivity, t), + ] + + const onMove = (event: PointerEvent) => { + const next = compute(event) + if (!next) return + current = next + useScene.getState().updateNodes(buildBatch(next)) + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + useViewer.getState().setInputDragging(false) + setDragging(false) + if (document.body.style.cursor === cursor) document.body.style.cursor = '' + } + + const onUp = () => { + cleanup() + // Single-undo dance: revert the fitting AND its followers to the + // pre-drag state while history is still paused, resume, then re-apply + // the final transform as one tracked change. + const reverts: { id: AnyNodeId; data: Partial }[] = ( + connectivity?.connections ?? [] + ).map((conn) => + conn.kind === 'rigid-node' + ? { id: conn.nodeId, data: { position: conn.startPosition } as Partial } + : { id: conn.nodeId, data: { path: conn.startPath } as Partial }, + ) + useScene.getState().updateNodes([ + { + id: fitting.id as AnyNodeId, + data: { position: initialPosition, rotation: initialRotation } as Partial, + }, + ...reverts.filter((u) => useScene.getState().nodes[u.id]), + ]) + resumeSceneHistory(useScene) + if (current) useScene.getState().updateNodes(buildBatch(current)) + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + } + + // Height: raise / lower the fitting. Anchored to the cursor's start Y so the + // fitting doesn't jump on grab; clamped so it never drops below the floor. + const heightCompute = (e: ThreeEvent) => { + const anchorWorld = toWorld(fitting.position as Point) + const startY = intersectVerticalY(e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld) + const baseY = fitting.position[1] + const fx = fitting.position[0] + const fz = fitting.position[2] + return (event: PointerEvent): FittingTransform | null => { + if (startY === null) return null + const y = intersectVerticalY(event.clientX, event.clientY, anchorWorld) + if (y === null) return null + const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep + const ny = Math.max(0, baseY + snap(y - startY, step)) + return { position: [fx, ny, fz] } + } + } + + // Rotate: spin the fitting about the active rotation axis. The cursor's + // bearing in the plane perpendicular to that axis (through the body center) + // drives the angle; world-frame premultiply so the axis means the screen + // X/Y/Z the user expects regardless of how the fitting is already turned. + const rotateCompute = (e: ThreeEvent) => { + const axis: RotationAxis = getRotationAxis() + const normal = AXIS_VECTORS[axis].clone() + const center = toWorld(fitting.position as Point) + const ref = axis === 'y' ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0) + const u = ref + .clone() + .sub(normal.clone().multiplyScalar(ref.dot(normal))) + .normalize() + const v = new Vector3().crossVectors(normal, u) + const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center) + const bearing = (clientX: number, clientY: number): number | null => { + const hit = intersect(clientX, clientY, plane) + if (!hit) return null + const d = hit.sub(center) + return Math.atan2(d.dot(v), d.dot(u)) + } + const startBearing = bearing(e.nativeEvent.clientX, e.nativeEvent.clientY) + const startQuat = new Quaternion().setFromEuler( + new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]), + ) + return (event: PointerEvent): FittingTransform | null => { + if (startBearing === null) return null + const b = bearing(event.clientX, event.clientY) + if (b === null) return null + const turn = new Quaternion().setFromAxisAngle(normal, b - startBearing) + const euler = new Euler().setFromQuaternion(turn.multiply(startQuat)) + return { rotation: [euler.x, euler.y, euler.z] } + } + } + + // Move: hand off to the ghost move tool the same way the floating drag + // engages it — `placementDragMode: true`. That flag (a) makes every handle + // hit-area inert (`handle-arrow.tsx`'s `hitAreaRaycast`) so this rig's own + // arrows stop swallowing the cursor's grid raycast, and (b) switches + // `MoveDuctFittingTool` to commit on pointer-release instead of a second + // click — press-drag-release, mid-air markup out of the way. + const onMoveDown = (e: ThreeEvent) => { + e.stopPropagation() + const editor = useEditor.getState() + editor.setPlacementDragMode(true) + // `setMovingNode`'s param union doesn't list duct-fitting, but the move + // tool is resolved by `movingNode.type` at runtime — the floating Move + // button engages a fitting the same way (`setMovingNode(node as any)`). + editor.setMovingNode(fitting as never) + useViewer.getState().setSelection({ selectedIds: [] }) + } + + const extent = useMemo(() => fittingExtentM(fitting), [fitting]) + const p = fitting.position as Point + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom * ARROW_SCALE + + if (dragging) { + return + } + return ( + + setHover(h ? 'height' : null)} + onPointerDown={beginDrag('ns-resize', heightCompute)} + placement={{ position: [p[0], p[1] + extent + ARROW_GAP, p[2]], baseScale }} + shape="chevron" + /> + setHover(h ? 'move' : null)} + onPointerDown={onMoveDown} + placement={{ position: p, baseScale }} + shape="cross" + /> + setHover(h ? 'rotate' : null)} + onPointerDown={beginDrag('grabbing', rotateCompute)} + placement={{ position: [p[0] + extent + ARROW_GAP, p[1], p[2]], baseScale }} + shape="curved-arrow" + /> + + ) } export default DuctFittingSelectionAffordance diff --git a/packages/nodes/src/duct-segment/definition.ts b/packages/nodes/src/duct-segment/definition.ts index 2f55db950..b0abea046 100644 --- a/packages/nodes/src/duct-segment/definition.ts +++ b/packages/nodes/src/duct-segment/definition.ts @@ -1,5 +1,6 @@ import { type AnyNode, type NodeDefinition, useScene } from '@pascal-app/core' import { createPathPointMoveAffordance } from '../shared/path-point-affordance' +import { createSegmentMoveAffordance } from '../shared/path-segment-affordance' import { buildDuctSegmentFloorplan } from './floorplan' import { buildDuctSegmentGeometry, ductPortDiameterIn } from './geometry' import { ductSegmentParametrics } from './parametrics' @@ -147,6 +148,9 @@ export const ductSegmentDefinition: NodeDefinition = { // `endpoint-handle` per path vertex; this drags the matching point. floorplanAffordances: { 'move-path-point': createPathPointMoveAffordance('duct-segment'), + // 2D twin of the 3D side-move arrows: slide a segment perpendicular to + // itself. (Length editing stays on the per-vertex hex handles.) + 'move-segment': createSegmentMoveAffordance('duct-segment'), }, // Selection-time path-point handles (drag to edit a committed run). diff --git a/packages/nodes/src/duct-segment/floorplan.ts b/packages/nodes/src/duct-segment/floorplan.ts index 26b9cd9fe..42f422230 100644 --- a/packages/nodes/src/duct-segment/floorplan.ts +++ b/packages/nodes/src/duct-segment/floorplan.ts @@ -5,6 +5,10 @@ import type { DuctSegmentNode } from './schema' const SUPPLY_CENTERLINE = '#d4825a' const RETURN_CENTERLINE = '#5a8ad4' const BODY_COLOR = '#9ca3af' +/** Move-arrow stand-off past the duct body, in plan meters. */ +const SIDE_ARROW_GAP = 0.27 +/** Below this plan length a segment / end has no usable direction. */ +const MIN_SEGMENT_LEN = 0.05 /** * Floor-plan representation of a duct run: the path drawn at the duct's @@ -96,6 +100,32 @@ export function buildDuctSegmentFloorplan( payload: { pointIndex: indexMap[k]! }, }) } + + // Side-move arrows: a front / back pair at each segment midpoint, sliding + // that segment perpendicular to itself. 2D twin of the 3D side-move + // arrows. The arrows stand one duct-radius + gap off the body; `angle` + // points each chevron outward along the segment normal. + const offset = diameterM / 2 + SIDE_ARROW_GAP + for (let k = 0; k < points.length - 1; k++) { + const a = points[k]! + const b = points[k + 1]! + const dx = b[0] - a[0] + const dz = b[1] - a[1] + const len = Math.hypot(dx, dz) + if (len < MIN_SEGMENT_LEN) continue + const normal: [number, number] = [-dz / len, dx / len] + const mid: FloorplanPoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] + for (const side of [1, -1] as const) { + const n: [number, number] = [normal[0] * side, normal[1] * side] + children.push({ + kind: 'move-arrow', + point: [mid[0] + n[0] * offset, mid[1] + n[1] * offset], + angle: Math.atan2(n[1], n[0]), + affordance: 'move-segment', + payload: { segmentIndex: indexMap[k]!, normal: n }, + }) + } + } } return { kind: 'group', children } diff --git a/packages/nodes/src/duct-segment/selection.tsx b/packages/nodes/src/duct-segment/selection.tsx index 01a56347d..7df760295 100644 --- a/packages/nodes/src/duct-segment/selection.tsx +++ b/packages/nodes/src/duct-segment/selection.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, analyzePortConnectivity, + type Cursor, type DuctSegmentNode, type PortConnectivity, pauseSceneHistory, @@ -12,7 +13,13 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor' +import { + ARROW_SCALE, + DimensionPill, + EDITOR_LAYER, + HandleArrow, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' @@ -21,6 +28,7 @@ import { DoubleSide, type Group, type Object3D, + OrthographicCamera, Plane, Quaternion, Raycaster, @@ -33,6 +41,7 @@ import { planElbowEndpointReaim, } from '../shared/elbow-endpoint-reaim' import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' +import { INCHES_TO_METERS } from './geometry' /** Corner hex-disc radius (meters) — matches the wall corner picker. */ const HANDLE_RADIUS = 0.11 @@ -41,6 +50,15 @@ const HANDLE_HOVER_COLOR = '#a5b4fc' /** Port-snap radius for dragged run endpoints (meters, XZ). */ const PORT_SNAP_RADIUS_M = 0.4 +// In-world arrow handle layout (meters) — mirrors the wall side handles so +// the duct affordances read as the same UI family. +const SIDE_ARROW_GAP = 0.27 +const SIDE_ARROW_MIN_OFFSET = 0.33 +const HEIGHT_ARROW_OFFSET = 0.3 +/** Below this horizontal segment length (m) a side-move arrow is pointless + * (a riser collapses to a point in plan) and is skipped. */ +const MIN_PLAN_SEGMENT_LEN = 0.05 + const UP = new Vector3(0, 1, 0) function snap(value: number, step: number): number { @@ -48,8 +66,25 @@ function snap(value: number, step: number): number { return Math.round(value / step) * step } +/** Half the run's cross-section (meters) — the arrow stand-off radius. */ +function runRadiusM(duct: DuctSegmentNode): number { + if (duct.shape === 'round') return (duct.diameter * INCHES_TO_METERS) / 2 + return (Math.max(duct.width, duct.height) * INCHES_TO_METERS) / 2 +} + type Point = [number, number, number] +type SideMoveHandle = { + key: string + /** Segment whose two vertices both translate along the normal. */ + segmentIndex: number + /** Unit XZ normal the arrow points along (away from the run body). */ + normal: [number, number] + /** World-local arrow position (already offset off the body). */ + position: Point + rotationY: number +} + /** * Selection-time editing for committed duct runs: one draggable handle * per path point. @@ -109,14 +144,39 @@ const DuctSegmentSelectionAffordance = () => { }, [ductId]) if (!duct || !target) return null - return createPortal(, target, undefined) + // Mount the handles in the duct group's PARENT (a sibling of the duct + // mesh), NOT inside the duct group itself. The selection outliner + // (`MergedOutlineNode`) traces every descendant mesh of the SELECTED node, + // so a hit-area cylinder parented under the duct would get swept into the + // duct's selection outline — the stray circle around the arrows. Walls / + // doors / windows dodge this the same way: their handle rig rides the + // parent, never the selected node. `DuctPointHandles` re-creates the duct + // group's own pose on an outer group so placements stay node-local. + const mount = target.parent ?? target + return createPortal(, mount, undefined) } const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Object3D }) => { const { camera, gl } = useThree() + // Outer group mirrors the duct group's local pose so handles placed in + // node-local path coords land exactly where the duct mesh sits, even though + // they're mounted in the parent (to stay out of the duct's selection + // outline). Mirrors the wall arrow rig's ride-group. + const outerRef = useRef(null) + useFrame(() => { + const outer = outerRef.current + if (!outer) return + outer.position.copy(target.position) + outer.quaternion.copy(target.quaternion) + outer.scale.copy(target.scale) + }) const unit = useViewer((s) => s.unit) const [draggingIndex, setDraggingIndex] = useState(null) const [hoverIndex, setHoverIndex] = useState(null) + // True while a side-move / height / extend arrow drag is live. The arrows + // (and the dragged one) hide during the drag — the window pointer handlers + // own it from pointer-down — exactly like the wall side handles. + const [arrowDragging, setArrowDragging] = useState(false) // Set while a drag is live; null otherwise. Holds everything the window // pointer handlers need so they never read stale React state. const dragRef = useRef<{ @@ -356,8 +416,140 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj window.addEventListener('pointercancel', onUp) } + /** + * Shared lifecycle for the in-world arrow handles (side-move / height / + * extend). Each frame `compute` turns the cursor into a full next path; + * the duct writes it and any mated fittings / runs follow via port + * connectivity. `makeCompute` is built at pointer-down so it can capture + * the grab anchor (height needs the cursor's start Y to avoid a teleport). + * History does the same single-undo dance as the corner-handle drag. + */ + const beginArrowDrag = + ( + cursor: string, + makeCompute: ( + e: ThreeEvent, + ) => (event: PointerEvent, initialPath: Point[]) => Point[] | null, + ) => + (e: ThreeEvent) => { + e.stopPropagation() + const initialPath = duct.path.map((p) => [...p] as Point) + const connectivity = analyzePortConnectivity(duct as AnyNode, useScene.getState().nodes) + const compute = makeCompute(e) + pauseSceneHistory(useScene) + useViewer.getState().setInputDragging(true) + setArrowDragging(true) + document.body.style.cursor = cursor + let currentPath = initialPath + + const buildBatch = (path: Point[]): { id: AnyNodeId; data: Partial }[] => [ + { id: duct.id as AnyNodeId, data: { path } as Partial }, + ...connectivityUpdatesForPath(connectivity, path), + ] + + const onMove = (event: PointerEvent) => { + const next = compute(event, initialPath) + if (!next) return + const same = next.every( + (p, i) => + p[0] === currentPath[i]![0] && + p[1] === currentPath[i]![1] && + p[2] === currentPath[i]![2], + ) + if (same) return + currentPath = next + useScene.getState().updateNodes(buildBatch(next)) + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + useViewer.getState().setInputDragging(false) + setArrowDragging(false) + if (document.body.style.cursor === cursor) document.body.style.cursor = '' + } + + const onUp = () => { + cleanup() + const moved = currentPath.some((p, i) => p.some((v, axis) => v !== initialPath[i]![axis])) + // Single-undo dance: revert the run AND its followers to their + // pre-drag state while history is still paused, resume, then re-apply + // the final batch as one tracked change. + const revertUpdates: { id: AnyNodeId; data: Partial }[] = ( + connectivity?.connections ?? [] + ).map((conn) => + conn.kind === 'rigid-node' + ? { id: conn.nodeId, data: { position: conn.startPosition } as Partial } + : { id: conn.nodeId, data: { path: conn.startPath } as Partial }, + ) + useScene + .getState() + .updateNodes([ + { id: duct.id as AnyNodeId, data: { path: initialPath } }, + ...revertUpdates.filter((u) => useScene.getState().nodes[u.id]), + ]) + resumeSceneHistory(useScene) + if (moved) useScene.getState().updateNodes(buildBatch(currentPath)) + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + } + + // Side-move: slide one segment perpendicular to itself. Both its vertices + // translate by the same plan-normal offset; neighbours stretch and any + // mated joint follows via connectivity. Grid-snapped (Shift bypasses). + const sideMoveCompute = + (handle: SideMoveHandle) => + () => + (event: PointerEvent, initialPath: Point[]): Point[] | null => { + const a = initialPath[handle.segmentIndex]! + const b = initialPath[handle.segmentIndex + 1]! + const mid: Point = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2] + const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(mid)) + const hit = intersect(event.clientX, event.clientY, plane) + if (!hit) return null + const local = toLocal(hit) + const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep + const signed = snap( + (local[0] - mid[0]) * handle.normal[0] + (local[2] - mid[2]) * handle.normal[1], + step, + ) + const ox = handle.normal[0] * signed + const oz = handle.normal[1] * signed + return initialPath.map((p, i) => + i === handle.segmentIndex || i === handle.segmentIndex + 1 + ? ([p[0] + ox, p[1], p[2] + oz] as Point) + : p, + ) + } + + // Height: raise / lower the WHOLE run uniformly. Anchored to the cursor's + // start Y so the run doesn't jump on grab; clamped so the lowest vertex + // never drops below the level floor. 3D-only — plan editing never changes + // elevation (see the floor-plan path-point affordance). + const heightCompute = (anchor: Point) => (e: ThreeEvent) => { + const anchorWorld = toWorld(anchor) + const startY = intersectVerticalY(e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld) + return (event: PointerEvent, initialPath: Point[]): Point[] | null => { + if (startY === null) return null + const y = intersectVerticalY(event.clientX, event.clientY, anchorWorld) + if (y === null) return null + const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep + let dy = snap(y - startY, step) + const minY = Math.min(...initialPath.map((p) => p[1])) + if (dy < -minY) dy = -minY + return initialPath.map((p) => [p[0], p[1] + dy, p[2]] as Point) + } + } + + const sideHandles = useMemo(() => getSideMoveHandles(duct), [duct]) + const heightHandle = useMemo(() => getHeightHandle(duct), [duct]) + return ( - + {duct.path.map((p, i) => ( ))} + {/* In-world move arrows — hidden while any handle drag is live (the + window pointer handlers own the gesture from pointer-down), exactly + like the wall side handles hide mid-drag. */} + {draggingIndex === null && !arrowDragging && ( + <> + {sideHandles.map((h) => ( + + ))} + {heightHandle && ( + + )} + + )} {draggingIndex !== null && duct.path[draggingIndex] && (() => { @@ -487,4 +702,100 @@ function HexHandle({ ) } +/** + * In-world chevron arrow handle — a thin wrapper over the editor's shared + * `HandleArrow` so the duct side-move / height arrows render as the exact + * same solid violet plate (depth-written, ink-edge outlined) the wall + * arrows use, instead of a parallel flat reimplementation. Lays flat in the + * XZ plane pointing along +X (yawed by `rotationY`); `upright` tips the + * chevron vertical for the height handle, matching the wall height arrow's + * `indicatorRotation`. Scales with ortho zoom for a constant on-screen size. + */ +function ArrowHandle({ + position, + rotationY = 0, + upright = false, + cursor = 'grab', + onPointerDown, +}: { + position: Point + rotationY?: number + upright?: boolean + cursor?: Cursor + onPointerDown: (e: ThreeEvent) => void +}) { + const [hovered, setHovered] = useState(false) + const { camera } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom * ARROW_SCALE + + return ( + + ) +} + +// Per-segment side-move arrows: a front / back pair at each segment midpoint +// that has a non-trivial plan length. Vertical risers (which collapse to a +// point in plan) are skipped. The arrows sit one run-radius + gap off the +// segment body along its plan normal; `rotationY` orients the flat chevron +// to point outward (matching `buildWallMoveHandle`). +function getSideMoveHandles(duct: DuctSegmentNode): SideMoveHandle[] { + const handles: SideMoveHandle[] = [] + const offset = runRadiusM(duct) + SIDE_ARROW_GAP + const effOffset = Math.max(offset, SIDE_ARROW_MIN_OFFSET) + for (let i = 0; i < duct.path.length - 1; i++) { + const a = duct.path[i]! + const b = duct.path[i + 1]! + const dx = b[0] - a[0] + const dz = b[2] - a[2] + const len = Math.hypot(dx, dz) + if (len < MIN_PLAN_SEGMENT_LEN) continue + const normal: [number, number] = [-dz / len, dx / len] + const mid: Point = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2] + for (const side of [1, -1] as const) { + const n: [number, number] = [normal[0] * side, normal[1] * side] + handles.push({ + key: `side-${i}-${side}`, + segmentIndex: i, + normal: n, + position: [mid[0] + n[0] * effOffset, mid[1], mid[2] + n[1] * effOffset], + rotationY: Math.atan2(-n[1], n[0]), + }) + } + } + return handles +} + +// Height arrow: a single upright chevron above the run's centroid. `anchor` +// is the centroid in node-local coords — the drag reads the cursor's start Y +// against a vertical plane through it so the run doesn't teleport on grab. +function getHeightHandle(duct: DuctSegmentNode): { position: Point; anchor: Point } | null { + if (duct.path.length < 2) return null + let x = 0 + let y = 0 + let z = 0 + for (const p of duct.path) { + x += p[0] + y += p[1] + z += p[2] + } + const count = duct.path.length + const anchor: Point = [x / count, y / count, z / count] + const top = Math.max(...duct.path.map((p) => p[1])) + return { + anchor, + position: [anchor[0], top + runRadiusM(duct) + HEIGHT_ARROW_OFFSET, anchor[2]], + } +} + export default DuctSegmentSelectionAffordance diff --git a/packages/nodes/src/shared/path-segment-affordance.ts b/packages/nodes/src/shared/path-segment-affordance.ts new file mode 100644 index 000000000..9d1a3b747 --- /dev/null +++ b/packages/nodes/src/shared/path-segment-affordance.ts @@ -0,0 +1,134 @@ +import { + type AnyNode, + type AnyNodeId, + analyzePortConnectivity, + type FloorplanAffordance, + type FloorplanAffordanceSession, + type PortConnectivity, + resolveConnectivityUpdates, + useScene, +} from '@pascal-app/core' +import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' + +/** + * Shared "side-move a path segment" floor-plan affordance for polyline + * distribution kinds (duct-segment / pipe-segment). It is the 2D counterpart + * of the in-world side-move arrows in the kind's 3D + * `affordanceTools.selection` handles. + * + * - **move-segment**: slide one segment perpendicular to itself. Both its + * vertices translate by the same plan-normal offset (the offset is the + * cursor's projection onto the segment normal); neighbours stretch and any + * mated joint follows via port connectivity. Grid-snapped (Shift bypasses). + * + * The vertices' Y (elevation) is always held — plan editing never changes + * height, matching the path-point affordance. Behavioral parity with the 3D + * selection arrows. (Length editing stays on the per-vertex hex handles.) + * + * Wired via `def.floorplanAffordances['move-segment']`; the floor-plan + * builder emits `move-arrow` primitives carrying the segment index so the + * dispatcher routes pointer-downs here. + */ +export type SegmentMovePayload = { + /** Index of the segment's first vertex (it spans [i, i+1]). */ + segmentIndex: number + /** Unit plan normal [nx, nz] the segment slides along. */ + normal: [number, number] +} + +type Point = [number, number, number] +type PathShape = { path: ReadonlyArray; id: AnyNodeId } + +const inert: FloorplanAffordanceSession = { + affectedIds: [], + apply() {}, + canCommit() { + return false + }, +} + +/** + * Connectivity snapshot + follow-update builder. Endpoints bear ports; an + * interior segment vertex never does, so the caller passes `analyze: false` + * to skip the work when neither moved vertex is a run end. + */ +function makeConnectivity( + node: N, + nodes: Record, + analyze: boolean, +): { + connectivity: PortConnectivity | null + affectedIds: AnyNodeId[] + followUpdates: (nextPath: Point[]) => { id: AnyNodeId; data: Partial }[] +} { + const connectivity = analyze ? analyzePortConnectivity(node as unknown as AnyNode, nodes) : null + const affectedIds: AnyNodeId[] = [ + node.id, + ...(connectivity?.connections.map((c) => c.nodeId) ?? []), + ] + const followUpdates = (nextPath: Point[]) => { + if (!connectivity) return [] + const preview = { + ...(node as unknown as Record), + path: nextPath, + } as AnyNode + return resolveConnectivityUpdates(connectivity, preview).filter( + (u) => useScene.getState().nodes[u.id], + ) + } + return { connectivity, affectedIds, followUpdates } +} + +export function createSegmentMoveAffordance( + kind: string, +): FloorplanAffordance { + return { + start({ node, payload, nodes }): FloorplanAffordanceSession { + const { segmentIndex, normal } = payload as SegmentMovePayload + const initialPath = node.path.map((p) => [...p] as Point) + const a = initialPath[segmentIndex] + const b = initialPath[segmentIndex + 1] + if (!a || !b) return { ...inert, affectedIds: [node.id] } + const lastIndex = initialPath.length - 1 + // A moved vertex bears a port only if it's a run end. + const touchesEnd = segmentIndex === 0 || segmentIndex + 1 === lastIndex + const { affectedIds, followUpdates } = makeConnectivity(node, nodes, touchesEnd) + const mid: WallPlanPoint = [(a[0] + b[0]) / 2, (a[2] + b[2]) / 2] + + return { + affectedIds, + apply({ planPoint, modifiers }) { + // Project the cursor onto the segment normal — that signed distance + // is how far the whole segment slides. Grid-snap the magnitude + // (Shift bypasses) so the slide lands on the same lattice as the + // other plan tools. + const signedRaw = + (planPoint[0] - mid[0]) * normal[0] + (planPoint[1] - mid[1]) * normal[1] + const signed = modifiers.shiftKey ? signedRaw : snapPointToGrid([signedRaw, 0])[0] + const ox = normal[0] * signed + const oz = normal[1] * signed + const nextPath = initialPath.map((p, i) => + i === segmentIndex || i === segmentIndex + 1 + ? ([p[0] + ox, p[1], p[2] + oz] as Point) + : p, + ) + useScene.getState().updateNodes([ + { id: node.id, data: { path: nextPath } as Partial as never }, + ...followUpdates(nextPath).map((u) => ({ + id: u.id, + data: u.data as Partial as never, + })), + ]) + }, + canCommit() { + const final = useScene.getState().nodes[node.id] as N | undefined + return ( + !!final && + (final as unknown as { type: string }).type === kind && + final.path.length >= 2 + ) + }, + } + }, + } +} From 2cb01c416d7e8db5954897a6957bf0ebcf6041de Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 21 Jun 2026 01:33:50 +0530 Subject: [PATCH 07/23] feat(mep): click-to-latch cube handles for duct + fitting editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hover-reveal / multi-handle selection rigs with a single click-to-latch cube that opens a directional cluster, shared between duct segments and fittings via a new selection-handles module (HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube). - Duct segment: per-vertex + run-center cubes reveal axis-locked move chevrons (down arrow always shown), plus a roll arc at the run center. - Duct fitting: center cube reveals six ±XYZ move arrows and three per-axis rotation arcs (oriented in place), replacing the old height/move/rotate trio with axis-cycling. - Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth. - thin chevron profile + press-drag-release commit retained. --- .../editor/handles/handle-arrow.tsx | 31 +- packages/nodes/src/duct-fitting/selection.tsx | 340 ++++--- packages/nodes/src/duct-segment/move-tool.tsx | 29 +- packages/nodes/src/duct-segment/selection.tsx | 854 ++++++++++-------- .../nodes/src/shared/selection-handles.tsx | 127 +++ 5 files changed, 869 insertions(+), 512 deletions(-) create mode 100644 packages/nodes/src/shared/selection-handles.tsx diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index 907646800..8c8f0589e 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -51,6 +51,13 @@ const CHEVRON_DEPTH = 0.08 const CHEVRON_BEVEL_THICKNESS = 0.035 const CHEVRON_BEVEL_SIZE = 0.03 const CHEVRON_BEVEL_SEGMENTS = 10 +// Slimmer extrude profile matching the legacy wall side handles +// (`wall-move-side-handles.tsx`) — opt-in via the `thin` prop so the chunkier +// default is preserved for every other handle that uses the shared chevron. +const CHEVRON_THIN_DEPTH = 0.045 +const CHEVRON_THIN_BEVEL_THICKNESS = 0.018 +const CHEVRON_THIN_BEVEL_SIZE = 0.02 +const CHEVRON_THIN_BEVEL_SEGMENTS = 8 const MOVE_CROSS_HALF_LENGTH = 0.36 const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03 const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12 @@ -90,6 +97,8 @@ export type HandleArrowProps = { indicatorRotation?: readonly [number, number, number] onPointerEnter?: PointerHandler onPointerLeave?: PointerHandler + // Extrude the slimmer wall-handle chevron profile (chevron shape only). + thin?: boolean } function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape { @@ -179,8 +188,9 @@ export function createRotateArrowHandleGeometry() { // Reused chevron+shaft silhouette. The chevron points along +X by default; // callers rotate it around Y for Z-axis handles and into a vertical frame for -// Y-axis handles. -export function createArrowHandleGeometry() { +// Y-axis handles. `thin` extrudes the slimmer wall-handle profile. +export function createArrowHandleGeometry(thin = false) { + const depth = thin ? CHEVRON_THIN_DEPTH : CHEVRON_DEPTH const shape = new Shape() shape.moveTo(CHEVRON_MAX_X, 0) shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH) @@ -191,16 +201,16 @@ export function createArrowHandleGeometry() { shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH) shape.lineTo(CHEVRON_MAX_X, 0) const geometry = new ExtrudeGeometry(shape, { - depth: CHEVRON_DEPTH, + depth, bevelEnabled: true, - bevelThickness: CHEVRON_BEVEL_THICKNESS, - bevelSize: CHEVRON_BEVEL_SIZE, + bevelThickness: thin ? CHEVRON_THIN_BEVEL_THICKNESS : CHEVRON_BEVEL_THICKNESS, + bevelSize: thin ? CHEVRON_THIN_BEVEL_SIZE : CHEVRON_BEVEL_SIZE, bevelOffset: 0, - bevelSegments: CHEVRON_BEVEL_SEGMENTS, + bevelSegments: thin ? CHEVRON_THIN_BEVEL_SEGMENTS : CHEVRON_BEVEL_SEGMENTS, curveSegments: 16, steps: 1, }) - geometry.translate(0, 0, -CHEVRON_DEPTH / 2) + geometry.translate(0, 0, -depth / 2) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() geometry.computeBoundingSphere() @@ -314,8 +324,8 @@ export function createEndpointHitAreaGeometry(radius: number) { return geometry } -function createHandleArrowGeometry(shape: HandleArrowShape) { - if (shape === 'chevron') return createArrowHandleGeometry() +function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) { + if (shape === 'chevron') return createArrowHandleGeometry(thin) if (shape === 'cross') return createMoveCrossHandleGeometry() if (shape === 'curved-arrow') return createRotateArrowHandleGeometry() if (shape === 'tracker') { @@ -459,9 +469,10 @@ export function HandleArrow({ onPointerDown, onPointerEnter, onPointerLeave, + thin = false, }: HandleArrowProps) { const visualShape = normalizeHandleArrowShape(shape, cursor) - const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape]) + const geometry = useMemo(() => createHandleArrowGeometry(visualShape, thin), [visualShape, thin]) const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape]) const indicatorMaterial = useHandleArrowMaterial(visualShape) const hitMaterial = useInvisibleHitAreaMaterial() diff --git a/packages/nodes/src/duct-fitting/selection.tsx b/packages/nodes/src/duct-fitting/selection.tsx index a60b8d55f..049dd58b5 100644 --- a/packages/nodes/src/duct-fitting/selection.tsx +++ b/packages/nodes/src/duct-fitting/selection.tsx @@ -13,7 +13,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { ARROW_SCALE, HandleArrow, useEditor } from '@pascal-app/editor' +import { swallowNextClick, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useState } from 'react' @@ -21,7 +21,6 @@ import { Euler, type Group, type Object3D, - OrthographicCamera, Plane, Quaternion, Raycaster, @@ -31,15 +30,18 @@ import { import { AXIS_VECTORS, cycleRotationAxis, - getRotationAxis, + ROTATE_STEP_RAD, type RotationAxis, } from '../shared/fitting-rotation' +import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles' import { fittingLegLength } from './ports' type Point = [number, number, number] /** Stand-off (meters) from the fitting body to each arrow. */ -const ARROW_GAP = 0.3 +const ARROW_GAP = 0.14 + +const UP = new Vector3(0, 1, 0) function snap(value: number, step: number): number { if (step <= 0) return value @@ -47,7 +49,7 @@ function snap(value: number, step: number): number { } /** Rough body radius (meters) — the larger of the fitting's two collar reaches, - * used to stand the arrows clear of the geometry. */ + * used to stand the handles clear of the geometry. */ function fittingExtentM(node: DuctFittingNode): number { const d2 = (node as { diameter2?: number }).diameter2 ?? node.diameter return Math.max(fittingLegLength(node.diameter), fittingLegLength(d2)) @@ -58,25 +60,21 @@ type FittingTransform = { position?: Point; rotation?: Point } /** * Selection-time affordances for a placed duct fitting — the 3D twin of the - * wall side handles, mirroring the duct-segment selection rig: + * duct-segment selection rig. A single CLICK-to-latch cube sits at the fitting + * center; clicking it opens (click again to close) a cluster of: * - * - **Height** (upright chevron above the body): raise / lower the fitting on - * a camera-facing vertical plane (riser editing). Connected runs follow. - * - **Move** (ground cross): hands off to `MoveDuctFittingTool` — the same - * click-to-place ghost move the floating Move button engages, with its own - * alignment guides, Ctrl-vertical, and Alt-detach. - * - **Rotate** (curved arrow): spin the fitting about the active rotation axis - * (Alt cycles it; R / T step it). Connected runs re-aim via port follow. + * - **Six move arrows** (±X / ±Y / ±Z): translate the whole fitting along one + * world axis. Connected runs follow via port connectivity. + * - **Three rotation arcs** (X / Y / Z): spin the fitting about each world + * axis. Connected runs re-aim via port follow. * * The handle rig is PORTALED into the fitting group's PARENT — never the * fitting group itself — because the selection outliner (`MergedOutlineNode`) * traces every descendant mesh of the SELECTED node, so a hit-area cylinder - * parented under the fitting would be swept into its selection outline (the - * stray circle around the arrows). Walls / doors / windows dodge it the same - * way. The fitting's local `position` is expressed in the parent's frame, so - * an identity group under the parent lets us place arrows at absolute - * level-local coords with world-aligned axes (height = world up, rotate on the - * world horizontal plane). + * parented under the fitting would be swept into its selection outline. Walls / + * doors / windows dodge it the same way. The fitting's local `position` is + * expressed in the parent's frame, so an identity group under the parent lets + * us place handles at absolute level-local coords with world-aligned axes. * * History does the single-undo dance: paused during the drag (live ticks are * untracked), reverted on release, resumed, then the final transform re-applied @@ -90,9 +88,9 @@ const DuctFittingSelectionAffordance = () => { return node?.type === 'duct-fitting' ? (node as DuctFittingNode) : null }) - // Alt cycles the active rotation axis while a single fitting is selected — - // the piece `def.keyboardActions` (R / T rotate) can't contribute. The pill - // above the fitting reads `useEditor.rotationAxis` to show it. + // Alt cycles the active rotation axis for the R / T keyboard rotate while a + // single fitting is selected (the gizmo's three arcs cover every axis on + // their own; this only keeps the keyboard action meaningful). const hasSelectedFitting = !!fitting useEffect(() => { if (!hasSelectedFitting) return @@ -134,9 +132,10 @@ const DuctFittingSelectionAffordance = () => { const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: Object3D }) => { const { camera, gl } = useThree() const [frame, setFrame] = useState(null) - const [hover, setHover] = useState<'height' | 'move' | 'rotate' | null>(null) - // True while a height / rotate drag is live — the arrows hide (the window - // pointer handlers own the gesture), exactly like the wall side handles. + // True while the cluster is latched open. Click the center cube to toggle. + const [open, setOpen] = useState(false) + // True while a move / rotate drag is live — the arrows hide (the window + // pointer handlers own the gesture), exactly like the duct-segment rig. const [dragging, setDragging] = useState(false) const makeRay = (clientX: number, clientY: number) => { @@ -173,6 +172,23 @@ const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: const toWorld = (p: Point): Vector3 => frame ? frame.localToWorld(new Vector3(p[0], p[1], p[2])) : new Vector3(p[0], p[1], p[2]) + /** Cursor's coordinate on one world axis, in the frame's local space. For Y + * it rides a camera-facing vertical plane; for X / Z it projects onto the + * horizontal plane through the fitting and reads back the local component. */ + const sampleAxis = ( + axis: RotationAxis, + clientX: number, + clientY: number, + anchorWorld: Vector3, + ): number | null => { + if (axis === 'y') return intersectVerticalY(clientX, clientY, anchorWorld) + const plane = new Plane().setFromNormalAndCoplanarPoint(UP, anchorWorld) + const hit = intersect(clientX, clientY, plane) + if (!hit || !frame) return null + const local = frame.worldToLocal(hit.clone()) + return axis === 'x' ? local.x : local.z + } + // Follow-updates for runs / fittings mated to this fitting, given a preview // transform. Endpoints whose ports didn't move resolve to a zero delta. const connectivityUpdates = ( @@ -187,11 +203,11 @@ const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: } /** - * Shared lifecycle for the height / rotate arrow drags. `makeCompute` is - * built at pointer-down so it can capture the grab anchor (the cursor's - * start Y / bearing) and avoid a teleport. Each frame `compute` turns the - * cursor into the fitting's next transform; the fitting writes it and any - * mated runs follow via port connectivity. + * Shared lifecycle for the move / rotate drags. `makeCompute` is built at + * pointer-down so it can capture the grab anchor (cursor's start coord / + * bearing) and avoid a teleport. Each frame `compute` turns the cursor into + * the fitting's next transform; the fitting writes it and any mated runs + * follow via port connectivity. Lands as one undo step. */ const beginDrag = ( @@ -234,6 +250,10 @@ const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: } const onUp = () => { + // Swallow the trailing synthetic click so it doesn't reach the + // background-click deselect handler (cleanup drops `inputDragging` + // synchronously here). + swallowNextClick() cleanup() // Single-undo dance: revert the fitting AND its followers to the // pre-drag state while history is still paused, resume, then re-apply @@ -261,111 +281,185 @@ const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: window.addEventListener('pointercancel', onUp) } - // Height: raise / lower the fitting. Anchored to the cursor's start Y so the - // fitting doesn't jump on grab; clamped so it never drops below the floor. - const heightCompute = (e: ThreeEvent) => { - const anchorWorld = toWorld(fitting.position as Point) - const startY = intersectVerticalY(e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld) - const baseY = fitting.position[1] - const fx = fitting.position[0] - const fz = fitting.position[2] - return (event: PointerEvent): FittingTransform | null => { - if (startY === null) return null - const y = intersectVerticalY(event.clientX, event.clientY, anchorWorld) - if (y === null) return null - const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep - const ny = Math.max(0, baseY + snap(y - startY, step)) - return { position: [fx, ny, fz] } + // Move: translate the fitting along one world axis, anchored to the cursor's + // start coord so it doesn't jump on grab. Y is clamped at the floor; Shift + // bypasses grid snapping. + const moveCompute = + (axis: RotationAxis) => + (e: ThreeEvent): ((event: PointerEvent) => FittingTransform | null) => { + const anchorWorld = toWorld(fitting.position as Point) + const start = sampleAxis(axis, e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld) + const base = [...fitting.position] as Point + const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2 + let lastDelta = Number.NaN + return (event: PointerEvent): FittingTransform | null => { + if (start === null) return null + const s = sampleAxis(axis, event.clientX, event.clientY, anchorWorld) + if (s === null) return null + const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep + const delta = snap(s - start, step) + if (delta === lastDelta) return null + lastDelta = delta + if (step > 0) triggerSFX('sfx:grid-snap') + const next = [...base] as Point + next[axisIndex] = ( + axis === 'y' ? Math.max(0, base[axisIndex] + delta) : base[axisIndex] + delta + ) as number + return { position: next } + } } - } - // Rotate: spin the fitting about the active rotation axis. The cursor's - // bearing in the plane perpendicular to that axis (through the body center) - // drives the angle; world-frame premultiply so the axis means the screen - // X/Y/Z the user expects regardless of how the fitting is already turned. - const rotateCompute = (e: ThreeEvent) => { - const axis: RotationAxis = getRotationAxis() - const normal = AXIS_VECTORS[axis].clone() - const center = toWorld(fitting.position as Point) - const ref = axis === 'y' ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0) - const u = ref - .clone() - .sub(normal.clone().multiplyScalar(ref.dot(normal))) - .normalize() - const v = new Vector3().crossVectors(normal, u) - const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center) - const bearing = (clientX: number, clientY: number): number | null => { - const hit = intersect(clientX, clientY, plane) - if (!hit) return null - const d = hit.sub(center) - return Math.atan2(d.dot(v), d.dot(u)) - } - const startBearing = bearing(e.nativeEvent.clientX, e.nativeEvent.clientY) - const startQuat = new Quaternion().setFromEuler( - new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]), - ) - return (event: PointerEvent): FittingTransform | null => { - if (startBearing === null) return null - const b = bearing(event.clientX, event.clientY) - if (b === null) return null - const turn = new Quaternion().setFromAxisAngle(normal, b - startBearing) - const euler = new Euler().setFromQuaternion(turn.multiply(startQuat)) - return { rotation: [euler.x, euler.y, euler.z] } + // Rotate: spin the fitting about one world axis. The cursor's bearing in the + // plane perpendicular to that axis (through the body center) drives the + // angle; world-frame premultiply so the axis means the screen X/Y/Z the user + // expects regardless of how the fitting is already turned. + const rotateCompute = + (axis: RotationAxis) => + (e: ThreeEvent): ((event: PointerEvent) => FittingTransform | null) => { + const normal = AXIS_VECTORS[axis].clone() + const center = toWorld(fitting.position as Point) + const ref = axis === 'y' ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0) + const u = ref + .clone() + .sub(normal.clone().multiplyScalar(ref.dot(normal))) + .normalize() + const v = new Vector3().crossVectors(normal, u) + const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center) + const bearing = (clientX: number, clientY: number): number | null => { + const hit = intersect(clientX, clientY, plane) + if (!hit) return null + const d = hit.sub(center) + return Math.atan2(d.dot(v), d.dot(u)) + } + const startBearing = bearing(e.nativeEvent.clientX, e.nativeEvent.clientY) + const startQuat = new Quaternion().setFromEuler( + new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]), + ) + return (event: PointerEvent): FittingTransform | null => { + if (startBearing === null) return null + const b = bearing(event.clientX, event.clientY) + if (b === null) return null + // Snap the turn to 45° steps; Shift = smooth (no snap). + const raw = b - startBearing + const delta = event.shiftKey ? raw : Math.round(raw / ROTATE_STEP_RAD) * ROTATE_STEP_RAD + const turn = new Quaternion().setFromAxisAngle(normal, delta) + const euler = new Euler().setFromQuaternion(turn.multiply(startQuat)) + return { rotation: [euler.x, euler.y, euler.z] } + } } - } - - // Move: hand off to the ghost move tool the same way the floating drag - // engages it — `placementDragMode: true`. That flag (a) makes every handle - // hit-area inert (`handle-arrow.tsx`'s `hitAreaRaycast`) so this rig's own - // arrows stop swallowing the cursor's grid raycast, and (b) switches - // `MoveDuctFittingTool` to commit on pointer-release instead of a second - // click — press-drag-release, mid-air markup out of the way. - const onMoveDown = (e: ThreeEvent) => { - e.stopPropagation() - const editor = useEditor.getState() - editor.setPlacementDragMode(true) - // `setMovingNode`'s param union doesn't list duct-fitting, but the move - // tool is resolved by `movingNode.type` at runtime — the floating Move - // button engages a fitting the same way (`setMovingNode(node as any)`). - editor.setMovingNode(fitting as never) - useViewer.getState().setSelection({ selectedIds: [] }) - } const extent = useMemo(() => fittingExtentM(fitting), [fitting]) const p = fitting.position as Point - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const baseScale = zoom * ARROW_SCALE + const base = extent + ARROW_GAP + + // Six whole-fitting move arrows, one per ± world axis. + const moveArrows: { + key: string + axis: RotationAxis + position: Point + rotationY: number + vertical?: 'up' | 'down' + cursor: Cursor + }[] = [ + { key: '+x', axis: 'x', position: [p[0] + base, p[1], p[2]], rotationY: 0, cursor: 'grab' }, + { + key: '-x', + axis: 'x', + position: [p[0] - base, p[1], p[2]], + rotationY: Math.PI, + cursor: 'grab', + }, + { + key: '+z', + axis: 'z', + position: [p[0], p[1], p[2] + base], + rotationY: -Math.PI / 2, + cursor: 'grab', + }, + { + key: '-z', + axis: 'z', + position: [p[0], p[1], p[2] - base], + rotationY: Math.PI / 2, + cursor: 'grab', + }, + { + key: '+y', + axis: 'y', + position: [p[0], p[1] + base, p[2]], + rotationY: 0, + vertical: 'up', + cursor: 'ns-resize', + }, + { + key: '-y', + axis: 'y', + position: [p[0], p[1] - base, p[2]], + rotationY: 0, + vertical: 'down', + cursor: 'ns-resize', + }, + ] + + // Three rotation arcs, one per world axis. Each arc wraps its axis (the + // shared `curved-arrow` wraps world +Y by default; `setFromUnitVectors` + // re-aims it) and sits at a diagonal offset in the plane it spins, so the + // three don't pile onto the move arrows. + const d = base * Math.SQRT1_2 + const rotateArcs: { key: string; axis: RotationAxis; position: Point; rotation: Point }[] = ( + ['x', 'y', 'z'] as RotationAxis[] + ).map((axis) => { + const q = new Quaternion().setFromUnitVectors(UP, AXIS_VECTORS[axis]) + // Spin the arc in place about its own axis so the grip sits where we want + // it without moving its position. + if (axis === 'z') { + q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.z, Math.PI / 4)) + } else if (axis === 'x') { + q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.x, (-145 * Math.PI) / 180)) + } else if (axis === 'y') { + q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.y, (-45 * Math.PI) / 180)) + } + const e = new Euler().setFromQuaternion(q) + const position: Point = + axis === 'x' + ? [p[0], p[1] + d, p[2] + d] + : axis === 'y' + ? [p[0] + d, p[1], p[2] + d] + : [p[0] + d, p[1] + d, p[2]] + return { key: `r${axis}`, axis, position, rotation: [e.x, e.y, e.z] } + }) if (dragging) { return } return ( - setHover(h ? 'height' : null)} - onPointerDown={beginDrag('ns-resize', heightCompute)} - placement={{ position: [p[0], p[1] + extent + ARROW_GAP, p[2]], baseScale }} - shape="chevron" - /> - setHover(h ? 'move' : null)} - onPointerDown={onMoveDown} - placement={{ position: p, baseScale }} - shape="cross" - /> - setHover(h ? 'rotate' : null)} - onPointerDown={beginDrag('grabbing', rotateCompute)} - placement={{ position: [p[0] + extent + ARROW_GAP, p[1], p[2]], baseScale }} - shape="curved-arrow" - /> + setOpen((o) => !o)} position={p} /> + {open && ( + <> + {moveArrows.map((a) => ( + + ))} + {rotateArcs.map((arc) => ( + + ))} + + )} ) } diff --git a/packages/nodes/src/duct-segment/move-tool.tsx b/packages/nodes/src/duct-segment/move-tool.tsx index 0c2c74407..89ee27fff 100644 --- a/packages/nodes/src/duct-segment/move-tool.tsx +++ b/packages/nodes/src/duct-segment/move-tool.tsx @@ -11,6 +11,7 @@ import { useScene, } from '@pascal-app/core' import { + consumePlacementDragRelease, DragBoundingBox, EDITOR_LAYER, markToolCancelConsumed, @@ -189,9 +190,12 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { connectivity?.preview({ path: nextPath }) } - const commit = (event: GridEvent) => { + const commit = (event: GridEvent, fromDragRelease = false) => { if (committed) return - if (Date.now() - activatedAtRef.current < 150) { + // The 150ms debounce only guards click-to-place against the arming click + // double-firing; a press-drag release is a distinct pointerup gesture, so + // it skips the guard (a quick drag-flick still commits). + if (!fromDragRelease && Date.now() - activatedAtRef.current < 150) { event.nativeEvent?.stopPropagation?.() return } @@ -252,14 +256,35 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { useEditor.getState().setMovingNode(null) } + // Press-drag-release: when the move was engaged by the drag gesture (the + // selection rig's move cross), `placementDragMode` is set, so commit on + // pointer-up at the last previewed path instead of waiting for a second + // click — same contract as the fitting move tool. + const onPlacementDragPointerUp = (event: PointerEvent) => { + if (!consumePlacementDragRelease(event)) return + if (!hasMovedRef.current) { + onCancel() + return + } + commit( + { + nativeEvent: event, + stopPropagation: () => event.stopPropagation(), + } as unknown as GridEvent, + true, + ) + } + emitter.on('grid:move', onMove) emitter.on('grid:click', commit) emitter.on('tool:cancel', onCancel) + window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { emitter.off('grid:move', onMove) emitter.off('grid:click', commit) emitter.off('tool:cancel', onCancel) + window.removeEventListener('pointerup', onPlacementDragPointerUp) connectivity?.clear() useAlignmentGuides.getState().clear() if (existedAtStart) setMeshHidden(false) diff --git a/packages/nodes/src/duct-segment/selection.tsx b/packages/nodes/src/duct-segment/selection.tsx index 7df760295..093fb0741 100644 --- a/packages/nodes/src/duct-segment/selection.tsx +++ b/packages/nodes/src/duct-segment/selection.tsx @@ -13,22 +13,15 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { - ARROW_SCALE, - DimensionPill, - EDITOR_LAYER, - HandleArrow, - useEditor, -} from '@pascal-app/editor' +import { DimensionPill, swallowNextClick, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { - DoubleSide, + Euler, type Group, type Object3D, - OrthographicCamera, Plane, Quaternion, Raycaster, @@ -41,23 +34,19 @@ import { planElbowEndpointReaim, } from '../shared/elbow-endpoint-reaim' import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' +import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles' import { INCHES_TO_METERS } from './geometry' -/** Corner hex-disc radius (meters) — matches the wall corner picker. */ -const HANDLE_RADIUS = 0.11 -const HANDLE_COLOR = '#818cf8' -const HANDLE_HOVER_COLOR = '#a5b4fc' /** Port-snap radius for dragged run endpoints (meters, XZ). */ const PORT_SNAP_RADIUS_M = 0.4 -// In-world arrow handle layout (meters) — mirrors the wall side handles so -// the duct affordances read as the same UI family. -const SIDE_ARROW_GAP = 0.27 -const SIDE_ARROW_MIN_OFFSET = 0.33 -const HEIGHT_ARROW_OFFSET = 0.3 -/** Below this horizontal segment length (m) a side-move arrow is pointless - * (a riser collapses to a point in plan) and is skipped. */ -const MIN_PLAN_SEGMENT_LEN = 0.05 +// In-world arrow handle layout (meters) — the arrows stand off the run body so +// they clear thick trunks. +const CORNER_ARROW_GAP = 0.18 +const CORNER_ARROW_MIN_OFFSET = 0.24 + +/** Roll snap increment — 45°, matching the fitting rotate step. Shift bypasses. */ +const ROLL_STEP_RAD = Math.PI / 4 const UP = new Vector3(0, 1, 0) @@ -74,20 +63,27 @@ function runRadiusM(duct: DuctSegmentNode): number { type Point = [number, number, number] -type SideMoveHandle = { +// What a corner arrow constrains its drag to: a single world-local axis +// (X / Z horizontal, Y vertical). +type DragKind = { axis: 'x' | 'z' | 'y' } + +type CornerArrow = { key: string - /** Segment whose two vertices both translate along the normal. */ - segmentIndex: number - /** Unit XZ normal the arrow points along (away from the run body). */ - normal: [number, number] - /** World-local arrow position (already offset off the body). */ + /** Path point this arrow drives. */ + index: number + kind: DragKind + /** World-local arrow position (offset off the point along its direction). */ position: Point rotationY: number + /** Set for the vertical pair — tips the flat chevron up / down. */ + vertical?: 'up' | 'down' + cursor: Cursor } /** - * Selection-time editing for committed duct runs: one draggable handle - * per path point. + * Selection-time editing for committed duct runs: each path point shows a + * cluster of directional arrows instead of a free-drag handle — four XZ + * chevrons (±X / ±Z) plus an up / down vertical pair. * * Handles are PORTALED into the duct's registered scene group so they * share its exact frame — path coords are node-local, and the level / @@ -95,19 +91,17 @@ type SideMoveHandle = { * Drag raycasts run in world space and convert hits back into the * group's local frame before writing the path. * - * Drag model: the point moves FREELY on the horizontal plane at its own - * height (no axis lock) — like a wall corner. Dragged run endpoints snap - * onto nearby typed ports so a loose run can be mated onto a fitting after - * the fact. When the dragged endpoint belongs to a straight run whose OTHER - * end sits on an elbow collar, the elbow re-aims to follow the drag - * (junction + far collar fixed, bend angle adapts) instead of port-snapping. + * Drag model: each arrow locks the point to one axis. Dragged run endpoints + * still snap onto nearby typed ports so a loose run can be mated onto a + * fitting after the fact, and when the dragged + * endpoint belongs to a straight run whose OTHER end sits on an elbow collar, + * the elbow re-aims to follow the drag (junction + far collar fixed, bend + * angle adapts) instead of port-snapping. * * Modifiers (mirroring the wall corner drag): * - **Alt** detaches: the joint breaks for this drag — the elbow does NOT * re-aim and mated fittings / runs do NOT follow; the endpoint moves on its * own (port re-mate still allowed so it can be reattached elsewhere). - * - **Cmd / Ctrl** switches to vertical movement (riser editing): XZ holds - * and the cursor drives Y. * - **Shift** bypasses grid snapping for a perfectly smooth precision drag. * * History does the single-undo dance: paused during the drag (the live @@ -172,11 +166,18 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj }) const unit = useViewer((s) => s.unit) const [draggingIndex, setDraggingIndex] = useState(null) - const [hoverIndex, setHoverIndex] = useState(null) - // True while a side-move / height / extend arrow drag is live. The arrows - // (and the dragged one) hide during the drag — the window pointer handlers - // own it from pointer-down — exactly like the wall side handles. - const [arrowDragging, setArrowDragging] = useState(false) + const [rolling, setRolling] = useState(false) + const [runMoving, setRunMoving] = useState(false) + // Which cube's arrow cluster is open. Hover proved too fiddly (the cursor has + // to bridge the gap between the dot and its offset arrows), so the cubes are + // CLICK-to-latch instead: clicking a cube opens its cluster and closes any + // other; clicking the same cube again closes it. A vertex cluster is keyed by + // its index, the run-center cluster by 'center'. Cleared on deselect (the + // whole rig unmounts) and after a drag commits. + type OpenCluster = number | 'center' | null + const [openCluster, setOpenCluster] = useState(null) + const toggleCluster = (key: Exclude) => + setOpenCluster((cur) => (cur === key ? null : key)) // Set while a drag is live; null otherwise. Holds everything the window // pointer handlers need so they never read stale React state. const dragRef = useRef<{ @@ -215,7 +216,7 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj /** * Local-frame Y where the cursor ray meets a vertical plane through - * `anchorWorld` that faces the camera — drives Alt-vertical (riser) drag. + * `anchorWorld` that faces the camera — drives the up / down vertical drag. * Null when the ray is parallel to the plane. */ const intersectVerticalY = ( @@ -282,23 +283,26 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj ) } - const onHandleDown = (index: number) => (e: ThreeEvent) => { + // A corner arrow drag: the point is locked to one axis (X / Z / Y). All the + // rich behaviour — port-snap, elbow re-aim, connectivity follow, single-undo + // — is shared with every arrow; only the + // cursor→point projection differs per `kind`. + const onHandleDown = (index: number, kind: DragKind) => (e: ThreeEvent) => { e.stopPropagation() const initialPath = duct.path.map((p) => [...p] as Point) const startPoint = initialPath[index]! const connectivity = analyzePortConnectivity(duct as AnyNode, useScene.getState().nodes) pauseSceneHistory(useScene) useViewer.getState().setInputDragging(true) - document.body.style.cursor = 'grabbing' + document.body.style.cursor = kind.axis === 'y' ? 'ns-resize' : 'grabbing' setDraggingIndex(index) const isEndpoint = index === 0 || index === initialPath.length - 1 // Elbow re-aim: if this is a straight run whose OTHER end sits on an // elbow collar, the elbow swings to follow the drag (junction + far - // collar fixed, bend angle adapts) — so the dragged end moves freely in - // any direction instead of being locked to the segment's own axis, the - // way a wall corner drags. Detected once against a drag-start snapshot. + // collar fixed, bend angle adapts). Detected once against a drag-start + // snapshot. const elbowEndpoint: ElbowEndpoint | null = isEndpoint ? detectElbowEndpoint('duct-segment', initialPath, index, useScene.getState().nodes) : null @@ -306,33 +310,34 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj const onMove = (event: PointerEvent) => { const drag = dragRef.current if (!drag) return - const current = drag.current - // Shift = precision: bypass grid snapping for a perfectly smooth - // drag (snap() is a no-op at step 0). + // Shift = precision: bypass grid snapping (snap() is a no-op at step 0). const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep - // Alt = detach: break the joint for this drag — the endpoint moves on - // its own, no elbow re-aim and no connectivity follow (it can still - // port-snap to re-mate elsewhere). Mirrors the wall corner drag. + // Alt = detach: break the joint for this drag (it can still port-snap to + // re-mate elsewhere). Mirrors the wall corner drag. const detached = event.altKey let next: Point | null = null - if (event.metaKey || event.ctrlKey) { - // Cmd/Ctrl = vertical: keep XZ fixed and drive Y off the cursor - // against a vertical plane through the point (riser editing). - const y = intersectVerticalY(event.clientX, event.clientY, toWorld(current)) - if (y !== null) next = [current[0], Math.max(0, snap(y, step)), current[2]] + if (kind.axis === 'y') { + // Vertical (riser): keep XZ pinned to the start and drive Y off the + // cursor against a vertical plane through the point. + const y = intersectVerticalY(event.clientX, event.clientY, toWorld(startPoint)) + if (y !== null) next = [startPoint[0], Math.max(0, snap(y, step)), startPoint[2]] } else { - // Default: free movement on the horizontal plane at the point's - // height (no axis lock). Endpoints can port-snap to mate a fitting. - const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current)) + // Horizontal: project the cursor onto the plane at the point's height, + // then lock to the arrow's axis (X / Z). + const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(startPoint)) const hit = intersect(event.clientX, event.clientY, plane) if (hit) { const local = toLocal(hit) - next = [snap(local[0], step), current[1], snap(local[2], step)] - // Port re-mate stays available whether detaching or free-dragging; + if (kind.axis === 'x') { + next = [snap(local[0], step), startPoint[1], startPoint[2]] + } else { + next = [startPoint[0], startPoint[1], snap(local[2], step)] + } + // Port re-mate stays available while detaching or free-dragging; // it's only suppressed while the elbow is actively re-aiming. if (isEndpoint && (detached || !drag.elbowEndpoint)) { const port = findNearestPortXZ( - [local[0], current[1], local[2]], + [next[0], startPoint[1], next[2]], collectScenePorts({ excludeNodeId: duct.id, systems: DUCT_PORT_SYSTEMS }), PORT_SNAP_RADIUS_M, ) @@ -341,17 +346,28 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj } } if (!next) return - if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return + if (next[0] === drag.current[0] && next[1] === drag.current[1] && next[2] === drag.current[2]) + return const batch = buildDragBatch(drag, next, detached) if (!batch) return drag.current = next drag.detached = detached + // Tick on each new snapped position — the same grid-snap SFX the draw + // tools fire; the player debounces rapid repeats (minIntervalMs). Only + // when the grid is live (step > 0): Shift-precision has nothing to snap. + if (step > 0) triggerSFX('sfx:grid-snap') useScene.getState().updateNodes(batch) } const onUp = () => { const drag = dragRef.current if (!drag) return + // Swallow the trailing synthetic click so it doesn't reach the + // background-click deselect handler — `cleanup()` drops `inputDragging` + // synchronously here, so without this the click that fires after + // pointerup would land with the drag gate already down and clear the + // selection. Mirrors `useHandleDrag`'s onUp. + swallowNextClick() drag.cleanup() dragRef.current = null setDraggingIndex(null) @@ -416,180 +432,277 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj window.addEventListener('pointercancel', onUp) } - /** - * Shared lifecycle for the in-world arrow handles (side-move / height / - * extend). Each frame `compute` turns the cursor into a full next path; - * the duct writes it and any mated fittings / runs follow via port - * connectivity. `makeCompute` is built at pointer-down so it can capture - * the grab anchor (height needs the cursor's start Y to avoid a teleport). - * History does the same single-undo dance as the corner-handle drag. - */ - const beginArrowDrag = - ( - cursor: string, - makeCompute: ( - e: ThreeEvent, - ) => (event: PointerEvent, initialPath: Point[]) => Point[] | null, - ) => - (e: ThreeEvent) => { - e.stopPropagation() - const initialPath = duct.path.map((p) => [...p] as Point) - const connectivity = analyzePortConnectivity(duct as AnyNode, useScene.getState().nodes) - const compute = makeCompute(e) - pauseSceneHistory(useScene) - useViewer.getState().setInputDragging(true) - setArrowDragging(true) - document.body.style.cursor = cursor - let currentPath = initialPath - - const buildBatch = (path: Point[]): { id: AnyNodeId; data: Partial }[] => [ - { id: duct.id as AnyNodeId, data: { path } as Partial }, - ...connectivityUpdatesForPath(connectivity, path), - ] + // Roll: spin the rect / oval cross-section about the run (length) axis. The + // cursor's bearing in the plane perpendicular to the run direction (taken in + // the cross-section's own width / height basis so the angle maps 1:1 to the + // visible profile) drives the `roll` field. Round runs look identical at any + // roll, so the gizmo isn't rendered for them. Roll doesn't move the ports + // (they sit on the path, whose positions are unchanged), so mated runs / + // fittings need no follow — just the single-undo dance on the scalar field. + const onRollDown = (e: ThreeEvent) => { + e.stopPropagation() + const axis = runAxisAndCenter(duct) + if (!axis) return + const startRoll = duct.roll + const dir = new Vector3(axis.dir[0], axis.dir[1], axis.dir[2]) + const worldDir = target.localToWorld(dir.clone()).sub(target.localToWorld(new Vector3())) + worldDir.normalize() + const center = toWorld(axis.center) + // Width / height axes at roll 0, mapped to world — the bearing basis. This + // is the same construction `rectSectionAxes` uses, so a turn of the cursor + // by θ rolls the section by θ. + const xBase = new Vector3().crossVectors(UP, dir) + if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0) + xBase.normalize() + const zBase = new Vector3().crossVectors(xBase, dir).normalize() + const u = target.localToWorld(xBase.clone()).sub(target.localToWorld(new Vector3())).normalize() + const v = target.localToWorld(zBase.clone()).sub(target.localToWorld(new Vector3())).normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(worldDir, center) + const bearing = (clientX: number, clientY: number): number | null => { + const hit = intersect(clientX, clientY, plane) + if (!hit) return null + const d = hit.sub(center) + return Math.atan2(d.dot(v), d.dot(u)) + } + const startBearing = bearing(e.nativeEvent.clientX, e.nativeEvent.clientY) + pauseSceneHistory(useScene) + useViewer.getState().setInputDragging(true) + document.body.style.cursor = 'grabbing' + setRolling(true) + let current = startRoll - const onMove = (event: PointerEvent) => { - const next = compute(event, initialPath) - if (!next) return - const same = next.every( - (p, i) => - p[0] === currentPath[i]![0] && - p[1] === currentPath[i]![1] && - p[2] === currentPath[i]![2], - ) - if (same) return - currentPath = next - useScene.getState().updateNodes(buildBatch(next)) - } + const onMove = (event: PointerEvent) => { + if (startBearing === null) return + const b = bearing(event.clientX, event.clientY) + if (b === null) return + // Snap the roll to 45° steps; Shift = smooth (no snap). + const raw = b - startBearing + const delta = event.shiftKey ? raw : Math.round(raw / ROLL_STEP_RAD) * ROLL_STEP_RAD + const next = startRoll + delta + if (next === current) return + current = next + useScene.getState().updateNode(duct.id, { roll: next }) + } - const cleanup = () => { - window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onUp) - window.removeEventListener('pointercancel', onUp) - useViewer.getState().setInputDragging(false) - setArrowDragging(false) - if (document.body.style.cursor === cursor) document.body.style.cursor = '' - } + const onUp = () => { + swallowNextClick() + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + useViewer.getState().setInputDragging(false) + document.body.style.cursor = '' + setRolling(false) + // Single-undo dance: revert to the pre-drag roll while paused, resume, + // then re-apply the final roll as one tracked change. + useScene.getState().updateNode(duct.id, { roll: startRoll }) + resumeSceneHistory(useScene) + if (current !== startRoll) useScene.getState().updateNode(duct.id, { roll: current }) + } - const onUp = () => { - cleanup() - const moved = currentPath.some((p, i) => p.some((v, axis) => v !== initialPath[i]![axis])) - // Single-undo dance: revert the run AND its followers to their - // pre-drag state while history is still paused, resume, then re-apply - // the final batch as one tracked change. - const revertUpdates: { id: AnyNodeId; data: Partial }[] = ( - connectivity?.connections ?? [] - ).map((conn) => - conn.kind === 'rigid-node' - ? { id: conn.nodeId, data: { position: conn.startPosition } as Partial } - : { id: conn.nodeId, data: { path: conn.startPath } as Partial }, - ) - useScene - .getState() - .updateNodes([ - { id: duct.id as AnyNodeId, data: { path: initialPath } }, - ...revertUpdates.filter((u) => useScene.getState().nodes[u.id]), - ]) - resumeSceneHistory(useScene) - if (moved) useScene.getState().updateNodes(buildBatch(currentPath)) - } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + } - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - window.addEventListener('pointercancel', onUp) + // Move the WHOLE run, locked to one world axis (X / Y / Z). Every path point + // shifts by the same delta; mated fittings / runs follow via connectivity, + // and the gesture lands as a single undo step (the same dance the per-point + // drag uses). The six center arrows each bind one ± axis; the projection per + // axis is the only thing that differs. + const onRunMoveDown = (axis: 'x' | 'y' | 'z') => (e: ThreeEvent) => { + e.stopPropagation() + const initialPath = duct.path.map((p) => [...p] as Point) + const center = runAxisAndCenter(duct)?.center ?? initialPath[0]! + const connectivity = analyzePortConnectivity(duct as AnyNode, useScene.getState().nodes) + const anchorWorld = toWorld(center) + // Grab offset: cursor's start coordinate on the locked axis, so the run + // doesn't jump on grab. + const sample = (clientX: number, clientY: number): number | null => { + if (axis === 'y') return intersectVerticalY(clientX, clientY, anchorWorld) + const plane = new Plane().setFromNormalAndCoplanarPoint(UP, anchorWorld) + const hit = intersect(clientX, clientY, plane) + if (!hit) return null + return toLocal(hit)[axis === 'x' ? 0 : 2] } + const startSample = sample(e.nativeEvent.clientX, e.nativeEvent.clientY) + const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2 + pauseSceneHistory(useScene) + useViewer.getState().setInputDragging(true) + document.body.style.cursor = axis === 'y' ? 'ns-resize' : 'grabbing' + setRunMoving(true) + let delta = 0 + + const shiftedPath = (d: number): Point[] => + initialPath.map((p) => { + const next = [...p] as Point + next[axisIndex] = ( + axis === 'y' ? Math.max(0, p[axisIndex] + d) : p[axisIndex] + d + ) as number + return next + }) + const batchFor = (path: Point[]): { id: AnyNodeId; data: Partial }[] => [ + { id: duct.id as AnyNodeId, data: { path } }, + ...connectivityUpdatesForPath(connectivity, path), + ] - // Side-move: slide one segment perpendicular to itself. Both its vertices - // translate by the same plan-normal offset; neighbours stretch and any - // mated joint follows via connectivity. Grid-snapped (Shift bypasses). - const sideMoveCompute = - (handle: SideMoveHandle) => - () => - (event: PointerEvent, initialPath: Point[]): Point[] | null => { - const a = initialPath[handle.segmentIndex]! - const b = initialPath[handle.segmentIndex + 1]! - const mid: Point = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2] - const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(mid)) - const hit = intersect(event.clientX, event.clientY, plane) - if (!hit) return null - const local = toLocal(hit) + const onMove = (event: PointerEvent) => { + if (startSample === null) return + const s = sample(event.clientX, event.clientY) + if (s === null) return const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep - const signed = snap( - (local[0] - mid[0]) * handle.normal[0] + (local[2] - mid[2]) * handle.normal[1], - step, - ) - const ox = handle.normal[0] * signed - const oz = handle.normal[1] * signed - return initialPath.map((p, i) => - i === handle.segmentIndex || i === handle.segmentIndex + 1 - ? ([p[0] + ox, p[1], p[2] + oz] as Point) - : p, - ) + const next = snap(s - startSample, step) + if (next === delta) return + delta = next + if (step > 0) triggerSFX('sfx:grid-snap') + useScene.getState().updateNodes(batchFor(shiftedPath(next))) } - // Height: raise / lower the WHOLE run uniformly. Anchored to the cursor's - // start Y so the run doesn't jump on grab; clamped so the lowest vertex - // never drops below the level floor. 3D-only — plan editing never changes - // elevation (see the floor-plan path-point affordance). - const heightCompute = (anchor: Point) => (e: ThreeEvent) => { - const anchorWorld = toWorld(anchor) - const startY = intersectVerticalY(e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld) - return (event: PointerEvent, initialPath: Point[]): Point[] | null => { - if (startY === null) return null - const y = intersectVerticalY(event.clientX, event.clientY, anchorWorld) - if (y === null) return null - const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep - let dy = snap(y - startY, step) - const minY = Math.min(...initialPath.map((p) => p[1])) - if (dy < -minY) dy = -minY - return initialPath.map((p) => [p[0], p[1] + dy, p[2]] as Point) + const onUp = () => { + swallowNextClick() + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + useViewer.getState().setInputDragging(false) + document.body.style.cursor = '' + setRunMoving(false) + // Single-undo dance: revert run + followers while paused, resume, re-apply + // the final shift as one tracked change. + const reverts: { id: AnyNodeId; data: Partial }[] = ( + connectivity?.connections ?? [] + ).map((conn) => + conn.kind === 'rigid-node' + ? { id: conn.nodeId, data: { position: conn.startPosition } as Partial } + : { id: conn.nodeId, data: { path: conn.startPath } as Partial }, + ) + useScene + .getState() + .updateNodes([ + { id: duct.id as AnyNodeId, data: { path: initialPath } }, + ...reverts.filter((u) => useScene.getState().nodes[u.id]), + ]) + resumeSceneHistory(useScene) + if (delta !== 0) useScene.getState().updateNodes(batchFor(shiftedPath(delta))) } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) } - const sideHandles = useMemo(() => getSideMoveHandles(duct), [duct]) - const heightHandle = useMemo(() => getHeightHandle(duct), [duct]) + const cornerArrows = useMemo(() => getCornerArrows(duct), [duct]) + const rollGizmo = useMemo(() => (duct.shape === 'round' ? null : runAxisAndCenter(duct)), [duct]) + // Run-center cube position (centroid centerline). The six whole-run move + // arrows + the roll arc are revealed on hover, like the per-vertex clusters. + const runCenter = useMemo(() => runAxisAndCenter(duct)?.center ?? null, [duct]) + // Six whole-run move arrows, one per ± world axis, offset off the center. + const centerArrows = useMemo(() => { + if (!runCenter) return [] + const base = Math.max(runRadiusM(duct) + CORNER_ARROW_GAP, CORNER_ARROW_MIN_OFFSET) + const dirs: { + key: string + axis: 'x' | 'y' | 'z' + offset: Point + rotationY: number + vertical?: 'up' | 'down' + cursor: Cursor + }[] = [ + { key: '+x', axis: 'x', offset: [base, 0, 0], rotationY: 0, cursor: 'grab' }, + { key: '-x', axis: 'x', offset: [-base, 0, 0], rotationY: Math.PI, cursor: 'grab' }, + { key: '+z', axis: 'z', offset: [0, 0, base], rotationY: -Math.PI / 2, cursor: 'grab' }, + { key: '-z', axis: 'z', offset: [0, 0, -base], rotationY: Math.PI / 2, cursor: 'grab' }, + { + key: '+y', + axis: 'y', + offset: [0, base, 0], + rotationY: 0, + vertical: 'up', + cursor: 'ns-resize', + }, + { + key: '-y', + axis: 'y', + offset: [0, -base, 0], + rotationY: 0, + vertical: 'down', + cursor: 'ns-resize', + }, + ] + return dirs.map((d) => ({ + ...d, + position: [ + runCenter[0] + d.offset[0], + runCenter[1] + d.offset[1], + runCenter[2] + d.offset[2], + ] as Point, + })) + }, [duct, runCenter]) return ( - {duct.path.map((p, i) => ( - { - e.stopPropagation() - setHoverIndex(i) - if (draggingIndex === null) document.body.style.cursor = 'grab' - }} - onPointerLeave={() => { - setHoverIndex((prev) => (prev === i ? null : prev)) - if (draggingIndex === null) document.body.style.cursor = '' - }} - position={p as Point} - /> - ))} - {/* In-world move arrows — hidden while any handle drag is live (the - window pointer handlers own the gesture from pointer-down), exactly - like the wall side handles hide mid-drag. */} - {draggingIndex === null && !arrowDragging && ( - <> - {sideHandles.map((h) => ( - - ))} - {heightHandle && ( -
diff --git a/packages/nodes/src/pipe-segment/selection.tsx b/packages/nodes/src/pipe-segment/selection.tsx index 5d3f2be56..6e684c550 100644 --- a/packages/nodes/src/pipe-segment/selection.tsx +++ b/packages/nodes/src/pipe-segment/selection.tsx @@ -28,10 +28,10 @@ import { Vector3, } from 'three' import { - detectElbowEndpoint, - type ElbowEndpoint, - planElbowEndpointReaim, -} from '../shared/elbow-endpoint-reaim' + detectFittingEndpoint, + type FittingEndpoint, + planFittingEndpointReaim, +} from '../shared/fitting-endpoint-reaim' import { collectScenePorts, DWV_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' /** Corner hex-disc radius (meters) — matches the duct corner handle. */ @@ -132,7 +132,7 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj // Set when the run's OTHER end sits on an elbow collar: the elbow re-aims // to follow this drag instead of translating rigidly (mutually exclusive // with `connectivity`-driven follow for this endpoint). - elbowEndpoint: ElbowEndpoint | null + fittingEndpoint: FittingEndpoint | null // True while Alt is held: the joint is detached for this drag, so the // final commit must omit elbow / connectivity updates. Tracked live so // `onUp` knows what the last frame did. @@ -186,13 +186,13 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj next: Point, detached: boolean, ): { id: AnyNodeId; data: Partial }[] | null => { - if (!detached && drag.elbowEndpoint) { - const plan = planElbowEndpointReaim(drag.elbowEndpoint, drag.index, next) + if (!detached && drag.fittingEndpoint) { + const plan = planFittingEndpointReaim(drag.fittingEndpoint, drag.index, next) // Out of the elbow's buildable turn range — hold this frame. if (!plan) return null return [ { id: pipe.id as AnyNodeId, data: { path: plan.path } }, - { id: plan.elbowUpdate.id, data: plan.elbowUpdate.data as Partial }, + { id: plan.fittingUpdate.id, data: plan.fittingUpdate.data }, ] } const path = pipe.path.map((p, i) => (i === drag.index ? next : p)) as Point[] @@ -241,8 +241,8 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj // collar fixed, bend angle adapts) — so the dragged end moves freely in // any direction instead of being locked to the segment's own axis, the // way a wall corner drags. Detected once against a drag-start snapshot. - const elbowEndpoint: ElbowEndpoint | null = isEndpoint - ? detectElbowEndpoint('pipe-segment', initialPath, index, useScene.getState().nodes) + const fittingEndpoint: FittingEndpoint | null = isEndpoint + ? detectFittingEndpoint('pipe-segment', initialPath, index, useScene.getState().nodes) : null const onMove = (event: PointerEvent) => { @@ -272,7 +272,7 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj next = [snap(local[0], step), current[1], snap(local[2], step)] // Port re-mate stays available whether detaching or free-dragging; // it's only suppressed while the elbow is actively re-aiming. - if (isEndpoint && (detached || !drag.elbowEndpoint)) { + if (isEndpoint && (detached || !drag.fittingEndpoint)) { const port = findNearestPortXZ( [local[0], current[1], local[2]], collectScenePorts({ excludeNodeId: pipe.id, systems: DWV_PORT_SYSTEMS }), @@ -308,16 +308,8 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj // detached nothing else moved, so only the run needs reverting. const revertUpdates: { id: AnyNodeId; data: Partial }[] = detached ? [] - : drag.elbowEndpoint - ? [ - { - id: drag.elbowEndpoint.elbow.id as AnyNodeId, - data: { - angle: drag.elbowEndpoint.elbow.angle, - rotation: drag.elbowEndpoint.elbow.rotation, - } as Partial, - }, - ] + : drag.fittingEndpoint + ? [drag.fittingEndpoint.revert] : (drag.connectivity?.connections ?? []).map((conn) => conn.kind === 'rigid-node' ? { id: conn.nodeId, data: { position: conn.startPosition } as Partial } @@ -350,7 +342,7 @@ const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Obj current: startPoint, cleanup, connectivity, - elbowEndpoint, + fittingEndpoint, detached: false, } window.addEventListener('pointermove', onMove) diff --git a/packages/nodes/src/shared/auto-fitting.ts b/packages/nodes/src/shared/auto-fitting.ts index a41a4fff5..1adc4405e 100644 --- a/packages/nodes/src/shared/auto-fitting.ts +++ b/packages/nodes/src/shared/auto-fitting.ts @@ -223,13 +223,38 @@ export function planTeeAtRunBody( if (axis.lengthSq() < 1e-10) return null axis.normalize() - // Branch leaves square to the run: project the drawn direction onto - // the plane perpendicular to the trunk axis. - const away = new Vector3(...awayDir) + // The branch FOLLOWS the drawn run's angle: the tee becomes a lateral + // whose `branchAngle` matches the actual turn the new run makes off the + // trunk, instead of forcing a square tap and kinking the drawn duct. + // `branchDir` is the drawn direction's component square to the trunk — + // it sets the PLANE the branch leans in; the lean amount comes from how + // much of `away` runs along the trunk vs. across it. + const away = new Vector3(...awayDir).normalize() + if (away.lengthSq() < 1e-10) return null const branchDir = away.clone().addScaledVector(axis, -away.dot(axis)) if (branchDir.lengthSq() < 1e-6) return null branchDir.normalize() + // `branchAngle` is measured off the +X (outlet / downstream) axis in the + // tee's local XZ plane, where +Z is the branch's square direction. So + // the angle is atan2(across-trunk component, along-trunk component) of + // the drawn run — 90° when square, <90° leaning downstream, >90° leaning + // upstream. Clamped to the schema's buildable 45–135° lateral range. + const acrossLen = Math.sqrt(Math.max(0, 1 - away.dot(axis) ** 2)) + const branchAngleDeg = Math.min( + 135, + Math.max(45, (Math.atan2(acrossLen, away.dot(axis)) * 180) / Math.PI), + ) + const phi = (branchAngleDeg * Math.PI) / 180 + // Actual branch outward direction at the (possibly clamped) angle — the + // new run starts at its collar. When unclamped this equals `away`, so + // the drawn duct continues straight out of the tee. + const branchOutDir = axis + .clone() + .multiplyScalar(Math.cos(phi)) + .addScaledVector(branchDir, Math.sin(phi)) + .normalize() + // Room check: both run legs must fit inside the hit segment with a // margin of real duct on each side. // Rect trunks present their area-equivalent round size at joints @@ -244,8 +269,9 @@ export function planTeeAtRunBody( const MIN_STUB = 0.08 if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null - // Local +X (the run) → axis, local +Z (the branch) → branchDir. Both - // pairs are perpendicular, so the basis transfer is exact. + // Local +X (the run) → axis, local +Z (the branch plane) → branchDir. + // Both pairs are perpendicular, so the basis transfer is exact and the + // local branch leg (cos φ, sin φ) lands on `branchOutDir` in world. const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1)) const worldFrame = frame(axis, branchDir) if (!localFrame || !worldFrame) return null @@ -256,7 +282,7 @@ export function planTeeAtRunBody( const inletTrim = P.clone().addScaledVector(axis, -legRun) const outletTrim = P.clone().addScaledVector(axis, legRun) - const collar = P.clone().addScaledVector(branchDir, legBranch) + const collar = P.clone().addScaledVector(branchOutDir, legBranch) const fitting = DuctFittingNode.parse({ object: 'node', @@ -273,6 +299,7 @@ export function planTeeAtRunBody( width2: branch.width, height2: branch.height, diameter2: branchDiameterIn, + branchAngle: branchAngleDeg, ductMaterial: 'sheet-metal', system: trunk.system, position: [P.x, P.y, P.z], @@ -574,6 +601,67 @@ export function planPipeElbowRealign( } } +// ─── Tee branch re-aim (run dragged off an existing tee's branch) ──── + +export type TeeBranchRealignPlan = { + /** Patch for the existing tee: new branch lean angle. The run axis and + * the tee's orientation stay fixed (inlet / outlet stay mated to the + * trunk) — only `branchAngle` changes. */ + update: { id: DuctFittingNode['id']; data: { branchAngle: number } } + /** Where the branch collar lands at the new angle — the dragged run's + * mated end rides here. */ + collarPoint: Point +} + +/** + * Re-aim a duct TEE's branch to follow a run dragged off its branch collar. + * + * Unlike the elbow (which re-orients its whole body), a tee's run legs stay + * mated to the trunk, so the body orientation is FIXED: the branch can only + * swing within the tee's local XZ plane (local +X = run axis, +Z = the + * square branch direction). `awayDir` (junction → dragged end) is projected + * onto that plane and read as the lean angle off +X — 90° square, <90° + * leaning downstream toward the outlet, >90° upstream toward the inlet — + * clamped to the schema's buildable 45–135° lateral range. + */ +export function planTeeBranchRealign( + tee: DuctFittingNode, + awayDir: Point, +): TeeBranchRealignPlan | null { + if (tee.fittingType !== 'tee') return null + const away = new Vector3(...awayDir) + if (away.lengthSq() < 1e-10) return null + away.normalize() + + const rot = new Quaternion().setFromEuler( + new Euler(tee.rotation[0], tee.rotation[1], tee.rotation[2]), + ) + const runAxis = new Vector3(1, 0, 0).applyQuaternion(rot) + const squareDir = new Vector3(0, 0, 1).applyQuaternion(rot) + const ax = away.dot(runAxis) + const az = away.dot(squareDir) + // Drag straight along the run axis (no square component) leaves the lean + // undefined — hold the frame. + if (Math.abs(ax) < 1e-9 && Math.abs(az) < 1e-9) return null + + const branchAngleDeg = Math.min(135, Math.max(45, (Math.atan2(az, ax) * 180) / Math.PI)) + const phi = (branchAngleDeg * Math.PI) / 180 + const branchDir = runAxis + .clone() + .multiplyScalar(Math.cos(phi)) + .addScaledVector(squareDir, Math.sin(phi)) + .normalize() + const collar = new Vector3(...tee.position).addScaledVector( + branchDir, + fittingLegLength(tee.diameter2), + ) + + return { + update: { id: tee.id, data: { branchAngle: branchAngleDeg } }, + collarPoint: [collar.x, collar.y, collar.z], + } +} + // ─── DWV pipe joints ───────────────────────────────────────────────── export type PipeElbowPlan = { diff --git a/packages/nodes/src/shared/elbow-endpoint-reaim.ts b/packages/nodes/src/shared/elbow-endpoint-reaim.ts deleted file mode 100644 index 773143230..000000000 --- a/packages/nodes/src/shared/elbow-endpoint-reaim.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { AnyNode, AnyNodeId, DuctFittingNode, PipeFittingNode } from '@pascal-app/core' -import { getDuctFittingPorts } from '../duct-fitting/ports' -import { getPipeFittingPorts } from '../pipe-fitting/ports' -import { planElbowRealign, planPipeElbowRealign } from './auto-fitting' - -/** - * Shared "drag a run end, the connected elbow re-aims" logic for the - * selection-time endpoint drag — duct (`duct-segment`) and DWV pipe - * (`pipe-segment`) alike, plus their 2D `move-path-point` twins. - * - * The motivating behaviour (mirrors how a wall corner drags): when you grab - * the free end of a straight run whose OTHER end sits on an elbow collar, - * the elbow's junction and its far (mated) collar stay put while the near - * collar swings to face the dragged end — the bend `angle` adjusts to fit. - * The run then goes from the re-aimed collar to wherever you drag, in ANY - * direction, instead of being locked to the segment's original axis. - * - * Detection is done ONCE at drag start (`detectElbowEndpoint`) against a - * snapshot of the elbow; the per-frame plan (`planElbowEndpointReaim`) - * always re-derives from that original snapshot, so live mutation of the - * elbow's angle/rotation never compounds. - */ - -type Point = [number, number, number] - -/** Distance (m) under which a run end counts as sitting on an elbow collar — - * matches core's port-coincidence epsilon. */ -const COINCIDENT_EPS_M = 0.05 - -/** Which run kind we're editing decides which fitting kind to look for. */ -type ElbowFitting = DuctFittingNode | PipeFittingNode - -export type ElbowEndpoint = { - /** The elbow node as it stood at drag start (the stable reference). */ - elbow: ElbowFitting - /** Which elbow collar the run's non-dragged end is mated to. */ - portId: 'inlet' | 'outlet' - /** The fitting kind, so the per-frame plan calls the right realign. */ - fittingType: 'duct-fitting' | 'pipe-fitting' -} - -export type ElbowEndpointReaimPlan = { - /** New path for the dragged run: the dragged end at the cursor, the - * elbow end pulled onto the re-aimed collar. */ - path: Point[] - /** Patch re-aiming the elbow (new turn angle + orientation). */ - elbowUpdate: { id: AnyNodeId; data: { angle: number; rotation: Point } } -} - -/** A run kind ('duct-segment' / 'pipe-segment') → the elbow fitting kind it - * mates to. Anything else has no elbow re-aim. */ -function fittingTypeForRun(runKind: string): 'duct-fitting' | 'pipe-fitting' | null { - if (runKind === 'duct-segment') return 'duct-fitting' - if (runKind === 'pipe-segment') return 'pipe-fitting' - return null -} - -function distSq(a: Point | readonly number[], b: Point | readonly number[]): number { - const dx = a[0]! - b[0]! - const dy = a[1]! - b[1]! - const dz = a[2]! - b[2]! - return dx * dx + dy * dy + dz * dz -} - -/** - * If `runPath` is a straight two-point run whose NON-dragged end sits on an - * elbow's inlet/outlet collar, return that elbow snapshot + the mated port - * id. `runKind` selects which fitting kind to scan for. Otherwise null — - * the caller falls back to plain free-drag. - */ -export function detectElbowEndpoint( - runKind: string, - runPath: ReadonlyArray, - draggedIndex: number, - nodes: Record, -): ElbowEndpoint | null { - if (runPath.length !== 2) return null - const fittingType = fittingTypeForRun(runKind) - if (!fittingType) return null - const elbowEnd = runPath[draggedIndex === 0 ? 1 : 0]! - const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M - for (const node of Object.values(nodes)) { - if (!node || node.type !== fittingType) continue - const elbow = node as ElbowFitting - if (elbow.fittingType !== 'elbow') continue - const ports = - fittingType === 'duct-fitting' - ? getDuctFittingPorts(elbow as DuctFittingNode) - : getPipeFittingPorts(elbow as PipeFittingNode) - for (const port of ports) { - if (port.id !== 'inlet' && port.id !== 'outlet') continue - if (distSq(port.position, elbowEnd) <= eps2) { - return { elbow, portId: port.id, fittingType } - } - } - } - return null -} - -/** - * Plan the run path + elbow re-aim for the dragged end at `draggedPoint`. - * The elbow swings its mated collar to face the junction→cursor direction; - * the run goes from that collar to the cursor. Returns null when the - * required turn falls outside the elbow's buildable 15–90° range (caller - * keeps the plain free-drag for that frame). - */ -export function planElbowEndpointReaim( - endpoint: ElbowEndpoint, - draggedIndex: number, - draggedPoint: Point, -): ElbowEndpointReaimPlan | null { - const { elbow, portId, fittingType } = endpoint - const j = elbow.position - const away: Point = [draggedPoint[0] - j[0], draggedPoint[1] - j[1], draggedPoint[2] - j[2]] - if (away[0] * away[0] + away[1] * away[1] + away[2] * away[2] < 1e-10) return null - const realign = - fittingType === 'duct-fitting' - ? planElbowRealign(elbow as DuctFittingNode, portId, away) - : planPipeElbowRealign(elbow as PipeFittingNode, portId, away) - if (!realign) return null - const path: Point[] = - draggedIndex === 0 ? [draggedPoint, realign.collarPoint] : [realign.collarPoint, draggedPoint] - return { path, elbowUpdate: realign.update } -} diff --git a/packages/nodes/src/shared/fitting-endpoint-reaim.ts b/packages/nodes/src/shared/fitting-endpoint-reaim.ts new file mode 100644 index 000000000..1e9ef28aa --- /dev/null +++ b/packages/nodes/src/shared/fitting-endpoint-reaim.ts @@ -0,0 +1,183 @@ +import type { AnyNode, AnyNodeId, DuctFittingNode, PipeFittingNode } from '@pascal-app/core' +import { getDuctFittingPorts } from '../duct-fitting/ports' +import { getPipeFittingPorts } from '../pipe-fitting/ports' +import { planElbowRealign, planPipeElbowRealign, planTeeBranchRealign } from './auto-fitting' + +/** + * Shared "drag a run end, the connected fitting re-aims" logic for the + * selection-time endpoint drag — duct (`duct-segment`) and DWV pipe + * (`pipe-segment`) alike, plus their 2D `move-path-point` twins. + * + * Two re-aim shapes share this path: + * + * - **Elbow** (duct + pipe): when you grab the free end of a straight run + * whose OTHER end sits on an elbow collar, the elbow's junction and far + * (mated) collar stay put while the near collar swings to face the + * dragged end — the bend `angle` adjusts to fit. Mirrors a wall corner. + * + * - **Tee branch** (duct only): when you grab the free end of a run mated + * to a tee's BRANCH collar, the tee's run legs stay locked to the trunk + * and only its `branchAngle` swings, so the branch keeps pointing at the + * dragged end. + * + * Detection runs ONCE at drag start (`detectFittingEndpoint`) against a + * snapshot of the fitting; the per-frame plan (`planFittingEndpointReaim`) + * always re-derives from that original snapshot, so live mutation of the + * fitting never compounds. + */ + +type Point = [number, number, number] + +/** Distance (m) under which a run end counts as sitting on a fitting collar — + * matches core's port-coincidence epsilon. */ +const COINCIDENT_EPS_M = 0.05 + +/** Which run kind we're editing decides which fitting kind to look for. */ +type ReaimFitting = DuctFittingNode | PipeFittingNode + +export type FittingEndpoint = { + /** The fitting node as it stood at drag start (the stable reference). */ + fitting: ReaimFitting + /** Whether the re-aim re-orients the whole elbow body or just swings a + * duct tee's branch lean. */ + reaim: 'elbow' | 'tee-branch' + /** Which fitting collar the run's non-dragged end is mated to. */ + portId: 'inlet' | 'outlet' | 'branch' + /** The fitting kind, so the per-frame plan calls the right realign. */ + fittingType: 'duct-fitting' | 'pipe-fitting' + /** Patch that restores the fitting to its drag-start state, for the + * single-undo dance's pre-resume revert. */ + revert: { id: AnyNodeId; data: Partial } +} + +export type FittingEndpointReaimPlan = { + /** New path for the dragged run: the dragged end at the cursor, the + * fitting end pulled onto the re-aimed collar. */ + path: Point[] + /** Patch re-aiming the fitting (elbow: angle + rotation; tee: branchAngle). */ + fittingUpdate: { id: AnyNodeId; data: Partial } +} + +/** A run kind ('duct-segment' / 'pipe-segment') → the fitting kind it + * mates to. Anything else has no re-aim. */ +function fittingTypeForRun(runKind: string): 'duct-fitting' | 'pipe-fitting' | null { + if (runKind === 'duct-segment') return 'duct-fitting' + if (runKind === 'pipe-segment') return 'pipe-fitting' + return null +} + +function distSq(a: Point | readonly number[], b: Point | readonly number[]): number { + const dx = a[0]! - b[0]! + const dy = a[1]! - b[1]! + const dz = a[2]! - b[2]! + return dx * dx + dy * dy + dz * dz +} + +/** + * If `runPath` is a straight two-point run whose NON-dragged end sits on a + * fitting collar that can re-aim, return that fitting snapshot + the mated + * port id and re-aim shape. `runKind` selects which fitting kind to scan + * for. Elbow inlet/outlet collars re-aim the whole elbow; a duct tee's + * branch collar swings only the branch. Otherwise null — the caller falls + * back to plain free-drag. + */ +export function detectFittingEndpoint( + runKind: string, + runPath: ReadonlyArray, + draggedIndex: number, + nodes: Record, +): FittingEndpoint | null { + if (runPath.length !== 2) return null + const fittingType = fittingTypeForRun(runKind) + if (!fittingType) return null + const fittingEnd = runPath[draggedIndex === 0 ? 1 : 0]! + const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M + for (const node of Object.values(nodes)) { + if (!node || node.type !== fittingType) continue + const fitting = node as ReaimFitting + const isElbow = fitting.fittingType === 'elbow' + // Tee-branch re-aim is duct-only (a sanitary tee has no adjustable + // branch lean). + const isDuctTee = fittingType === 'duct-fitting' && fitting.fittingType === 'tee' + if (!isElbow && !isDuctTee) continue + const ports = + fittingType === 'duct-fitting' + ? getDuctFittingPorts(fitting as DuctFittingNode) + : getPipeFittingPorts(fitting as PipeFittingNode) + for (const port of ports) { + if (isElbow && port.id !== 'inlet' && port.id !== 'outlet') continue + if (isDuctTee && port.id !== 'branch') continue + if (distSq(port.position, fittingEnd) > eps2) continue + if (isElbow) { + return { + fitting, + reaim: 'elbow', + portId: port.id as 'inlet' | 'outlet', + fittingType, + revert: { + id: fitting.id as AnyNodeId, + data: { angle: fitting.angle, rotation: fitting.rotation } as Partial, + }, + } + } + return { + fitting, + reaim: 'tee-branch', + portId: 'branch', + fittingType, + revert: { + id: fitting.id as AnyNodeId, + data: { branchAngle: (fitting as DuctFittingNode).branchAngle } as Partial, + }, + } + } + } + return null +} + +/** + * Plan the run path + fitting re-aim for the dragged end at `draggedPoint`. + * The fitting swings its mated collar to face the junction→cursor direction; + * the run goes from that collar to the cursor. Returns null when the + * required turn falls outside the fitting's buildable range (caller keeps + * the plain free-drag for that frame). + */ +export function planFittingEndpointReaim( + endpoint: FittingEndpoint, + draggedIndex: number, + draggedPoint: Point, +): FittingEndpointReaimPlan | null { + const { fitting, reaim, portId, fittingType } = endpoint + const j = fitting.position + const away: Point = [draggedPoint[0] - j[0], draggedPoint[1] - j[1], draggedPoint[2] - j[2]] + if (away[0] * away[0] + away[1] * away[1] + away[2] * away[2] < 1e-10) return null + + if (reaim === 'tee-branch') { + const realign = planTeeBranchRealign(fitting as DuctFittingNode, away) + if (!realign) return null + const path: Point[] = + draggedIndex === 0 ? [draggedPoint, realign.collarPoint] : [realign.collarPoint, draggedPoint] + return { + path, + fittingUpdate: { + id: realign.update.id as AnyNodeId, + data: realign.update.data as Partial, + }, + } + } + + const realign = + fittingType === 'duct-fitting' + ? planElbowRealign(fitting as DuctFittingNode, portId, away) + : planPipeElbowRealign(fitting as PipeFittingNode, portId, away) + if (!realign) return null + const path: Point[] = + draggedIndex === 0 ? [draggedPoint, realign.collarPoint] : [realign.collarPoint, draggedPoint] + return { + path, + fittingUpdate: { + id: realign.update.id as AnyNodeId, + data: realign.update.data as Partial, + }, + } +} diff --git a/packages/nodes/src/shared/fitting-rotation.ts b/packages/nodes/src/shared/fitting-rotation.ts index 67a328ae7..cf72c5980 100644 --- a/packages/nodes/src/shared/fitting-rotation.ts +++ b/packages/nodes/src/shared/fitting-rotation.ts @@ -1,5 +1,5 @@ import { type AnyNode, useScene } from '@pascal-app/core' -import { useEditor } from '@pascal-app/editor' +import { triggerSFX, useEditor } from '@pascal-app/editor' import { Euler, Quaternion, Vector3 } from 'three' import type { DuctFittingNode } from '../duct-fitting/schema' @@ -47,4 +47,5 @@ export function rotateFittingNode(node: AnyNode, steps: 1 | -1): void { useScene.getState().updateNode(fitting.id, { rotation: rotateEulerWorld(fitting.rotation, getRotationAxis(), steps), }) + triggerSFX('sfx:item-rotate') } diff --git a/packages/nodes/src/shared/path-point-affordance.ts b/packages/nodes/src/shared/path-point-affordance.ts index 0a055ec94..6e2129a4f 100644 --- a/packages/nodes/src/shared/path-point-affordance.ts +++ b/packages/nodes/src/shared/path-point-affordance.ts @@ -10,10 +10,10 @@ import { } from '@pascal-app/core' import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' import { - detectElbowEndpoint, - type ElbowEndpoint, - planElbowEndpointReaim, -} from './elbow-endpoint-reaim' + detectFittingEndpoint, + type FittingEndpoint, + planFittingEndpointReaim, +} from './fitting-endpoint-reaim' /** * Shared "drag a path point" floor-plan affordance for polyline @@ -65,17 +65,17 @@ export function createPathPointMoveAffordance c.nodeId) ?? []), ] @@ -109,16 +109,17 @@ export function createPathPointMoveAffordance as never }, { - id: plan.elbowUpdate.id, - data: plan.elbowUpdate.data as Partial as never, + id: plan.fittingUpdate.id, + data: plan.fittingUpdate.data as Partial as never, }, ]) return From 7892a2d3e7ab09639d73fcd69951fdde97f56db6 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 21 Jun 2026 14:51:29 +0530 Subject: [PATCH 11/23] =?UTF-8?q?feat(mep):=20vertical-offset=20auto-routi?= =?UTF-8?q?ng=20on=20duct=20center-cube=20=C2=B1Y=20drag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifting/lowering a connected run with the run-center cube now keeps each connected end welded to its stationary partner instead of dragging the whole network. Run-to-run ends get the classic S/Z offset (two elbows + plumb riser, partner trimmed back one leg); elbow-connected ends form a clean L — the existing elbow stays put and re-aims its collar vertical, with one new top elbow + riser reconnecting to the lifted endpoint. The offset is ghosted live and minted as a single undo step on release. Co-Authored-By: Claude Opus 4.7 --- packages/nodes/src/duct-segment/selection.tsx | 111 +++++++- packages/nodes/src/duct-segment/tool.tsx | 33 +-- packages/nodes/src/shared/mep-ghost.tsx | 59 +++++ packages/nodes/src/shared/vertical-offset.ts | 247 ++++++++++++++++++ 4 files changed, 410 insertions(+), 40 deletions(-) create mode 100644 packages/nodes/src/shared/mep-ghost.tsx create mode 100644 packages/nodes/src/shared/vertical-offset.ts diff --git a/packages/nodes/src/duct-segment/selection.tsx b/packages/nodes/src/duct-segment/selection.tsx index d4da1c2a5..7445d4313 100644 --- a/packages/nodes/src/duct-segment/selection.tsx +++ b/packages/nodes/src/duct-segment/selection.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, analyzePortConnectivity, type Cursor, + type DuctFittingNode, type DuctSegmentNode, type PortConnectivity, pauseSceneHistory, @@ -34,8 +35,10 @@ import { type FittingEndpoint, planFittingEndpointReaim, } from '../shared/fitting-endpoint-reaim' +import { DuctSegmentGhost, FittingGhost } from '../shared/mep-ghost' import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles' +import { planVerticalOffsets, type VerticalOffsetPlan } from '../shared/vertical-offset' import { INCHES_TO_METERS } from './geometry' /** Port-snap radius for dragged run endpoints (meters, XZ). */ @@ -181,6 +184,14 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj const [draggingIndex, setDraggingIndex] = useState(null) const [rolling, setRolling] = useState(false) const [runMoving, setRunMoving] = useState(false) + // Auto-routed offset preview shown while the run-center cube's ±Y arrows + // lift / lower a connected run: the elbows + plumb riser the release will + // mint are ghosted (the run itself lifts live). Null when the move is a + // plain translate (open ends, or a move too short to offset). + const [verticalGhost, setVerticalGhost] = useState<{ + fittings: DuctFittingNode[] + risers: DuctSegmentNode[] + } | null>(null) // Which cube's arrow cluster is open. Hover proved too fiddly (the cursor has // to bridge the gap between the dot and its offset arrows), so the cubes are // CLICK-to-latch instead: clicking a cube opens its cluster and closes any @@ -640,8 +651,19 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj e.stopPropagation() const initialPath = duct.path.map((p) => [...p] as Point) const center = runAxisAndCenter(duct)?.center ?? initialPath[0]! - const connectivity = analyzePortConnectivity(duct as AnyNode, useScene.getState().nodes) + const nodesById = useScene.getState().nodes + const connectivity = analyzePortConnectivity(duct as AnyNode, nodesById) const anchorWorld = toWorld(center) + // Cross-section the auto-routed offset fittings inherit (vertical move). + const profile = { + shape: duct.shape, + diameter: duct.diameter, + width: duct.width, + height: duct.height, + } + // Other runs' / fittings' ports, for the offset planner to read a + // connected partner's collar direction at each lifted end. + const scenePorts = collectScenePorts({ excludeNodeId: duct.id, systems: DUCT_PORT_SYSTEMS }) // Grab offset: cursor's start coordinate along the locked direction (signed // distance for the horizontal run-relative line), so the run doesn't jump // on grab. @@ -659,6 +681,10 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj document.body.style.cursor = kind.axis === 'y' ? 'ns-resize' : 'grabbing' setRunMoving(true) let delta = 0 + // Set to the last frame's auto-routed offset plan (vertical move with a + // connected end and room to offset); null when the frame was a plain + // translate. `onUp` reads it to decide create-on-release vs. re-translate. + let offsetPlan: VerticalOffsetPlan | null = null const shiftedPath = (d: number): Point[] => initialPath.map((p) => { @@ -685,7 +711,39 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj if (next === delta) return delta = next if (step > 0) triggerSFX('sfx:grid-snap') - useScene.getState().updateNodes(batchFor(shiftedPath(next))) + // Vertical move: connected ends stay welded to their (stationary) + // partner via an auto-routed elbow → riser → elbow offset, instead of + // dragging the whole network up. Open ends (or moves too short to + // offset) fall back to the plain translate-with-follow. + offsetPlan = + kind.axis === 'y' && next !== 0 + ? planVerticalOffsets({ + duct, + dy: next, + profile, + connections: connectivity?.connections ?? [], + scenePorts, + nodesById, + }) + : null + if (offsetPlan) { + // Lift the run to the offset path; trim run-offset partners back one + // leg, and let connectivity-follow (seeded from followPath, which zeroes + // the offset ends) lift any FITTING / open partner so its elbow rides + // up and its riser lengthens into a clean L. Ghost the new elbows + + // riser; they're minted for real on release. + useScene + .getState() + .updateNodes([ + { id: duct.id as AnyNodeId, data: { path: offsetPlan.ductPath } }, + ...offsetPlan.updates.filter((u) => useScene.getState().nodes[u.id]), + ...connectivityUpdatesForPath(connectivity, offsetPlan.followPath), + ]) + setVerticalGhost({ fittings: offsetPlan.fittings, risers: offsetPlan.risers }) + } else { + setVerticalGhost(null) + useScene.getState().updateNodes(batchFor(shiftedPath(next))) + } } const onUp = () => { @@ -696,15 +754,24 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj useViewer.getState().setInputDragging(false) document.body.style.cursor = '' setRunMoving(false) + setVerticalGhost(null) // Single-undo dance: revert run + followers while paused, resume, re-apply // the final shift as one tracked change. const reverts: { id: AnyNodeId; data: Partial }[] = ( connectivity?.connections ?? [] - ).map((conn) => - conn.kind === 'rigid-node' - ? { id: conn.nodeId, data: { position: conn.startPosition } as Partial } - : { id: conn.nodeId, data: { path: conn.startPath } as Partial }, - ) + ).map((conn) => { + if (conn.kind !== 'rigid-node') { + return { id: conn.nodeId, data: { path: conn.startPath } as Partial } + } + // Rigid partners translate (position) — but a connected elbow may have + // RE-AIMED (rotation + angle) for the clean-L offset, so restore those + // from the drag-start snapshot too. + const start = nodesById[conn.nodeId] as Record | undefined + const data: Record = { position: conn.startPosition } + if (start?.rotation !== undefined) data.rotation = start.rotation + if (start?.angle !== undefined) data.angle = start.angle + return { id: conn.nodeId, data: data as Partial } + }) useScene .getState() .updateNodes([ @@ -712,7 +779,26 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj ...reverts.filter((u) => useScene.getState().nodes[u.id]), ]) resumeSceneHistory(useScene) - if (delta !== 0) useScene.getState().updateNodes(batchFor(shiftedPath(delta))) + if (delta === 0) return + const plan = offsetPlan + if (plan) { + // Create the auto-routed elbows + riser and apply the lifted path, + // run-partner trims, and the connectivity-follow for any fitting / open + // partner (seeded from followPath) as one tracked change. + useScene.getState().applyNodeChanges({ + create: [...plan.fittings, ...plan.risers].map((node) => ({ + node, + parentId: (duct.parentId ?? undefined) as AnyNodeId | undefined, + })), + update: [ + { id: duct.id as AnyNodeId, data: { path: plan.ductPath } }, + ...plan.updates.filter((u) => useScene.getState().nodes[u.id]), + ...connectivityUpdatesForPath(connectivity, plan.followPath), + ], + }) + } else { + useScene.getState().updateNodes(batchFor(shiftedPath(delta))) + } } window.addEventListener('pointermove', onMove) @@ -793,6 +879,15 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj return ( + {/* Auto-routed offset preview (vertical run-center move): the elbows + + plumb riser the release will mint, ghosted while the run lifts live. + Built from the same nodes the commit creates, so what's shown lands. */} + {verticalGhost?.fittings.map((f) => ( + + ))} + {verticalGhost?.risers.map((r) => ( + + ))} {/* Per-vertex affordances — hidden while a drag / roll is live (the window pointer handlers own the gesture). Each vertex shows a small cube; CLICKING the cube latches its directional cluster open (click again to diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index 238c4cf41..26681d93e 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -27,14 +27,11 @@ import { DoubleSide, type Group, Matrix4, - Mesh, - MeshBasicMaterial, Path, Shape, ShapeGeometry, Vector3, } from 'three' -import { buildDuctFittingGeometry } from '../duct-fitting/geometry' import { getDuctFittingPorts } from '../duct-fitting/ports' import { planCrossAtRunBody, @@ -44,6 +41,7 @@ import { } from '../shared/auto-fitting' import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' import { LevelOffsetGroup } from '../shared/level-offset-group' +import { FittingGhost } from '../shared/mep-ghost' import { collectScenePorts, DUCT_PORT_SYSTEMS, @@ -1028,35 +1026,6 @@ const DuctSegmentTool = () => { ) } -/** - * Translucent ghost of an auto-inserted fitting, built from the same - * geometry the placed node uses so the preview matches the result. The - * node already carries its level-local `position` / `rotation`, so the - * group is rendered at the origin (the builder bakes the transform in via - * the renderer normally — here we apply it ourselves). - */ -function FittingGhost({ fitting }: { fitting: DuctFittingNode }) { - const ghost = useMemo(() => { - const group = buildDuctFittingGeometry(fitting) - group.position.set(...fitting.position) - group.rotation.set(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]) - group.traverse((child) => { - child.layers.set(EDITOR_LAYER) - if (child instanceof Mesh) { - child.material = new MeshBasicMaterial({ - color: '#818cf8', - depthTest: false, - transparent: true, - opacity: PREVIEW_OPACITY, - }) - child.renderOrder = 999 - } - }) - return group - }, [fitting]) - return -} - /** * Build a horizontal `ShapeGeometry` for a ceiling polygon (with holes) in * level-local XZ, laid flat in the XZ plane. Mirrors the ceiling renderer / diff --git a/packages/nodes/src/shared/mep-ghost.tsx b/packages/nodes/src/shared/mep-ghost.tsx new file mode 100644 index 000000000..4ffe48b8f --- /dev/null +++ b/packages/nodes/src/shared/mep-ghost.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { DuctFittingNode, DuctSegmentNode } from '@pascal-app/core' +import { EDITOR_LAYER } from '@pascal-app/editor' +import { useMemo } from 'react' +import { Mesh, MeshBasicMaterial } from 'three' +import { buildDuctFittingGeometry } from '../duct-fitting/geometry' +import { buildDuctSegmentGeometry } from '../duct-segment/geometry' + +/** Indigo-400 — the shared MEP preview accent (matches the draw-tool ghost). */ +export const GHOST_COLOR = '#818cf8' +export const GHOST_OPACITY = 0.55 + +/** Repaint every mesh in `group` as a translucent, depth-test-free preview. */ +function ghostify(group: { traverse: (cb: (child: object) => void) => void }) { + group.traverse((child) => { + if (child instanceof Mesh) { + child.layers.set(EDITOR_LAYER) + child.material = new MeshBasicMaterial({ + color: GHOST_COLOR, + depthTest: false, + transparent: true, + opacity: GHOST_OPACITY, + }) + child.renderOrder = 999 + } + }) +} + +/** + * Translucent ghost of a duct fitting, built from the same geometry the + * placed node uses so the preview matches the result. The node carries its + * level-local `position` / `rotation`, applied here on the group (the + * renderer normally bakes that in). + */ +export function FittingGhost({ fitting }: { fitting: DuctFittingNode }) { + const ghost = useMemo(() => { + const group = buildDuctFittingGeometry(fitting) + group.position.set(...fitting.position) + group.rotation.set(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]) + ghostify(group) + return group + }, [fitting]) + return +} + +/** + * Translucent ghost of a duct-segment run. Path coords are level-local and + * the node's transform is identity, so the built group renders at the origin + * — the same frame the fitting ghosts use. + */ +export function DuctSegmentGhost({ duct }: { duct: DuctSegmentNode }) { + const ghost = useMemo(() => { + const group = buildDuctSegmentGeometry(duct) + ghostify(group) + return group + }, [duct]) + return +} diff --git a/packages/nodes/src/shared/vertical-offset.ts b/packages/nodes/src/shared/vertical-offset.ts new file mode 100644 index 000000000..fb57a653d --- /dev/null +++ b/packages/nodes/src/shared/vertical-offset.ts @@ -0,0 +1,247 @@ +import { + type AnyNode, + type AnyNodeId, + DuctSegmentNode, + type PortConnection, +} from '@pascal-app/core' +import { fittingLegLength } from '../duct-fitting/ports' +import type { DuctFittingNode } from '../duct-fitting/schema' +import { + type DuctProfile, + planElbowAtPort, + planElbowRealign, + profileDiameterIn, +} from './auto-fitting' +import type { ScenePort } from './ports' + +/** + * Center-cube vertical-move auto-routing for duct runs. + * + * When a run is lifted / lowered with the run-center cube's ±Y arrows, a + * RUN-connected end should stay welded to its (stationary) partner by way of + * an offset: an elbow on the lifted run, a plumb riser down to the partner's + * height, and a second elbow that meets the partner — the classic duct S/Z + * offset. Without it, plain connectivity-follow would translate the collinear + * partner run straight up too (it has no turn to absorb the lift), dragging + * the whole network along. + * + * RUN-connected end (partner stays at its old height): + * - top elbow at the lifted endpoint, turning the run axis → vertical; + * - a plumb riser straight down (same X/Z) to one leg above the partner; + * - bottom elbow at the partner joint, turning vertical → the partner's + * axis; the partner run is trimmed back one leg so the elbow replaces + * that stretch. + * The lifted run is trimmed back one leg at the offset end so it meets the + * top elbow's collar instead of overlapping it. + * + * ELBOW-connected end (a clean L): the existing elbow STAYS PUT and re-aims so + * its mated collar swings vertical (flattening toward a straight coupling); a + * plumb riser rises from that collar to a single new TOP elbow that turns back + * along the run axis onto the lifted endpoint. One new elbow + one riser — no + * horizontal jog. Non-elbow fittings (and elbows whose re-aim is out of the + * buildable 15–90° range) ride up via plain connectivity-follow instead. Open + * ends likewise just ride up. + */ + +type Point = [number, number, number] + +/** Joint-coincidence epsilon (m), matching core's port connectivity. */ +const COINCIDENT_EPS_M = 0.05 +/** Shortest riser worth minting — below this there's no room to offset, so + * the caller keeps the plain vertical translate. */ +const MIN_RISER_M = 0.05 + +export type VerticalOffsetPlan = { + /** The lifted run's new path: every point raised by `dy`, each RUN-offset + * end trimmed back one elbow-leg to meet its top elbow (fitting / open ends + * keep their lifted endpoint). */ + ductPath: Point[] + /** The path to seed the caller's connectivity-follow from: identical to the + * lifted run except each RUN-offset end is reset to its ORIGINAL height, so + * its trimmed partner shows zero delta (we trim it via `updates` instead) + * while a FITTING / open end shows `+dy` — lifting its elbow rigidly and + * lengthening that elbow's riser into a clean L. */ + followPath: Point[] + /** Two elbows per RUN-offset end (top + bottom). */ + fittings: DuctFittingNode[] + /** One plumb riser per RUN-offset end. */ + risers: DuctSegmentNode[] + /** Partner-run trims (the run mated at each RUN-offset end pulled back one + * leg). Fitting partners are rigid and never updated. */ + updates: { id: AnyNodeId; data: Partial }[] +} + +function distSq(a: Point | readonly number[], b: Point | readonly number[]): number { + const dx = a[0]! - b[0]! + const dy = a[1]! - b[1]! + const dz = a[2]! - b[2]! + return dx * dx + dy * dy + dz * dz +} + +/** Outward unit direction at the run endpoint `idx` (0 = start, last = end). */ +function endpointOutwardDir(path: ReadonlyArray, idx: number): Point { + const last = path.length - 1 + const [a, b] = idx === 0 ? [path[0]!, path[1]!] : [path[last]!, path[last - 1]!] + const d: Point = [a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!] + const len = Math.hypot(d[0], d[1], d[2]) + return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len] +} + +/** Minimal ScenePort the elbow planner needs (position + direction + system). */ +function portLike(position: Point, direction: Point, system: string): ScenePort { + return { + id: 'x', + nodeId: 'x' as AnyNodeId, + position, + direction, + diameter: 0, + system, + } as unknown as ScenePort +} + +/** A plumb riser duct-segment between two points, carrying the run's profile. */ +function makeRiser(from: Point, to: Point, duct: DuctSegmentNode): DuctSegmentNode { + return DuctSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: duct.name ?? 'Duct run', + path: [from, to], + shape: duct.shape, + diameter: duct.diameter, + width: duct.width, + height: duct.height, + roll: duct.roll, + ductMaterial: duct.ductMaterial, + insulated: duct.insulated, + insulationR: duct.insulationR, + system: duct.system, + }) +} + +export function planVerticalOffsets(args: { + duct: DuctSegmentNode + /** Signed vertical move (meters); +up / -down. */ + dy: number + profile: DuctProfile + /** The drag-start connectivity snapshot's connections. */ + connections: PortConnection[] + /** Scene ports (excluding the lifted run) for partner direction lookup. */ + scenePorts: ScenePort[] + /** Drag-start node snapshots keyed by id, so a connected elbow's ORIGINAL + * pose can be re-aimed each frame (the live store carries the last frame's + * re-aim). */ + nodesById: Record +}): VerticalOffsetPlan | null { + const { duct, dy, profile, connections, scenePorts, nodesById } = args + if (connections.length === 0) return null + const leg = fittingLegLength(profileDiameterIn(profile)) + // Need room for both elbow legs plus a real riser between them. + if (Math.abs(dy) - 2 * leg < MIN_RISER_M) return null + + const startPath = duct.path.map((p) => [...p] as Point) + const last = startPath.length - 1 + const vSign = Math.sign(dy) + const up: Point = [0, vSign, 0] + const down: Point = [0, -vSign, 0] + const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M + + // Lift the whole run; connected ends get adjusted below. + const ductPath = startPath.map((p) => [p[0], p[1] + dy, p[2]] as Point) + // Seed for the caller's connectivity-follow: starts as the lifted path, but + // each RUN-offset end is reset to its original point below so that end shows + // zero delta (its partner is trimmed via `updates`, not dragged), while any + // FITTING / open end stays lifted so its partner follows. + const followPath = startPath.map((p) => [p[0], p[1] + dy, p[2]] as Point) + + const fittings: DuctFittingNode[] = [] + const risers: DuctSegmentNode[] = [] + const updates: { id: AnyNodeId; data: Partial }[] = [] + let offsetAny = false + + for (const endIdx of last > 0 ? [0, last] : [0]) { + const endPos = startPath[endIdx]! + // Partner port sitting on this end, owned by a snapshotted connection. + const partnerPort = scenePorts.find( + (sp) => + distSq(sp.position, endPos) <= eps2 && connections.some((c) => c.nodeId === sp.nodeId), + ) + if (!partnerPort) continue // open end → just rides up + const conn = connections.find((c) => c.nodeId === partnerPort.nodeId)! + + const ductPortDir = endpointOutwardDir(startPath, endIdx) + const liftedEnd: Point = [endPos[0], endPos[1] + dy, endPos[2]] + + // FITTING partner: a clean L. The existing ELBOW stays put and re-aims so + // its mated collar swings vertical (flattening toward a straight coupling); + // a plumb riser rises from that collar to a single new TOP elbow that turns + // back along the run axis onto the lifted endpoint. Non-elbow fittings (or + // an elbow whose re-aim falls out of buildable range) ride up via plain + // connectivity-follow instead — no offset minted. + if (conn.kind !== 'run') { + const partner = nodesById[conn.nodeId] + if (!partner || partner.type !== 'duct-fitting') continue + const elbow = partner as DuctFittingNode + if (elbow.fittingType !== 'elbow') continue + // Re-aim the existing elbow's mated collar to vertical; its other collar + // (mated to the rest of the run) stays fixed. + const realign = planElbowRealign(elbow, partnerPort.id, up) + if (!realign) continue + // Top elbow: junction plumb above the elbow at the lifted height. Its + // "existing run" is the riser, whose top port faces UP; the new run + // leaves back along the run axis (awayBack) onto the lifted endpoint. + const topJunction: Point = [elbow.position[0], liftedEnd[1], elbow.position[2]] + const awayBack: Point = [-ductPortDir[0], -ductPortDir[1], -ductPortDir[2]] + const top = planElbowAtPort(portLike(topJunction, up, duct.system), awayBack, profile) + if (!top) continue + + fittings.push(top.fitting) + // Plumb riser: the re-aimed elbow's vertical collar up to the top elbow's + // riser collar (its trimmedPortPoint) — both at the elbow's XZ. + risers.push(makeRiser(realign.collarPoint, top.trimmedPortPoint, duct)) + // Re-aim patch for the existing elbow. + updates.push({ id: elbow.id, data: realign.update.data as Partial }) + // Lifted run ends on the top elbow's outlet collar (= the lifted + // endpoint); zero this end's connectivity-follow delta — the re-aim + // already reconnects it. + ductPath[endIdx] = top.collarPoint + followPath[endIdx] = [...startPath[endIdx]!] as Point + offsetAny = true + continue + } + + const partnerDir: Point = [ + partnerPort.direction[0], + partnerPort.direction[1], + partnerPort.direction[2], + ] + + // Bottom elbow at the partner joint: turn from the partner's axis up the + // riser. Partner run trims to its inlet collar. + const bottom = planElbowAtPort(portLike(endPos, partnerDir, duct.system), up, profile) + // Top elbow at the lifted endpoint: turn from the run axis down the + // riser. Lifted run trims to its inlet collar. + const top = planElbowAtPort(portLike(liftedEnd, ductPortDir, duct.system), down, profile) + if (!bottom || !top) return null + + fittings.push(bottom.fitting, top.fitting) + risers.push(makeRiser(bottom.collarPoint, top.collarPoint, duct)) + ductPath[endIdx] = top.trimmedPortPoint + // This end's partner is trimmed (below), not dragged — keep its follow-seed + // at the original height so connectivity-follow sees zero delta here. + followPath[endIdx] = [...startPath[endIdx]!] as Point + + // Trim the partner run's mated end back one leg. + const path = conn.startPath.map((p) => [...p] as Point) + const tip = path.findIndex((p) => distSq(p, endPos) <= eps2) + if (tip !== -1) { + path[tip] = bottom.trimmedPortPoint + updates.push({ id: conn.nodeId, data: { path } as Partial }) + } + offsetAny = true + } + + if (!offsetAny) return null + return { ductPath, followPath, fittings, risers, updates } +} From 64d8049b8d9a0c5e2d42d01369ced6204844b858 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 22 Jun 2026 16:16:03 +0530 Subject: [PATCH 12/23] Add roof accessory placement guides Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters. --- packages/nodes/src/box-vent/tool.tsx | 19 +- packages/nodes/src/chimney/tool.tsx | 16 +- packages/nodes/src/cupola/tool.tsx | 16 +- packages/nodes/src/dormer/move-tool.tsx | 149 +++--- .../nodes/src/dormer/placement-guides.tsx | 153 ++++++ packages/nodes/src/dormer/tool.tsx | 12 +- .../nodes/src/dormer/use-dormer-placement.ts | 5 + packages/nodes/src/eyebrow-vent/tool.tsx | 19 +- packages/nodes/src/gutter/tool.tsx | 21 +- packages/nodes/src/ridge-vent/tool.tsx | 22 +- .../shared/roof-surface-placement-guides.ts | 292 ++++++++++ .../nodes/src/shared/roof-surface.test.ts | 25 +- packages/nodes/src/shared/roof-surface.ts | 505 ++++++++++++++++++ packages/nodes/src/skylight/tool.tsx | 16 +- packages/nodes/src/solar-panel/tool.tsx | 16 +- packages/nodes/src/turbine-vent/tool.tsx | 19 +- 16 files changed, 1223 insertions(+), 82 deletions(-) create mode 100644 packages/nodes/src/dormer/placement-guides.tsx create mode 100644 packages/nodes/src/shared/roof-surface-placement-guides.ts diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index 23c8af63f..27dd2da61 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { boxVentDefinition } from './definition' import BoxVentPreview from './preview' @@ -85,6 +90,15 @@ const BoxVentTool = () => { setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode({ + ...previewNode, + rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), + }), + }) event.stopPropagation() } @@ -109,6 +123,7 @@ const BoxVentTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [vent.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -120,8 +135,9 @@ const BoxVentTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -131,6 +147,7 @@ const BoxVentTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index 19a7ada01..e8455b021 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -16,6 +16,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { chimneyDefinition } from './definition' import ChimneyPreview from './preview' @@ -102,6 +107,12 @@ const ChimneyTool = () => { setSegmentXform(xform) setHitLocal([hit.localX, hit.localY, hit.localZ]) setPreviewSegment(hit.segment) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode, { segment: hit.segment }), + }) event.stopPropagation() } @@ -126,6 +137,7 @@ const ChimneyTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [chimney.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -137,8 +149,9 @@ const ChimneyTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -149,6 +162,7 @@ const ChimneyTool = () => { setSegmentXform(null) setHitLocal(null) setPreviewSegment(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && segmentXform && hitLocal && previewSegment && ( diff --git a/packages/nodes/src/cupola/tool.tsx b/packages/nodes/src/cupola/tool.tsx index 2e1373944..fbf004deb 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { cupolaDefinition } from './definition' import CupolaPreview from './preview' @@ -77,6 +82,12 @@ const CupolaTool = () => { setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode), + }) event.stopPropagation() } @@ -101,6 +112,7 @@ const CupolaTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [cupola.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -112,8 +124,9 @@ const CupolaTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -123,6 +136,7 @@ const CupolaTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/dormer/move-tool.tsx b/packages/nodes/src/dormer/move-tool.tsx index 150fdd959..b84620366 100644 --- a/packages/nodes/src/dormer/move-tool.tsx +++ b/packages/nodes/src/dormer/move-tool.tsx @@ -11,6 +11,7 @@ import { import { useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo } from 'react' +import { DormerPlacementGuides } from './placement-guides' import DormerPreview from './preview' import { useDormerPlacement } from './use-dormer-placement' @@ -74,84 +75,94 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { } }, [node.id, isNew]) - const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({ - initialRotation: originalRotation, - relativeStart: { - position: [...node.position] as [number, number, number], - roofSegmentId: node.roofSegmentId, - }, - onCommit: (hit, rotation) => { - const state = useScene.getState() + const { activeBuildingId, segmentXform, hitSegment, hitLocal, ghostRotation } = + useDormerPlacement({ + initialRotation: originalRotation, + relativeStart: { + position: [...node.position] as [number, number, number], + roofSegmentId: node.roofSegmentId, + }, + onCommit: (hit, rotation) => { + const state = useScene.getState() - // Strip the `isNew` / `isTransient` flags — only used to mark a - // clone or in-flight move that hasn't been committed yet. - const cleanedMeta = (() => { - const m = - node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) - ? (node.metadata as Record) - : {} - const { - isNew: _isNew, - isTransient: _isTransient, - ...rest - } = m as { - isNew?: boolean - isTransient?: boolean - } - return Object.keys(rest).length > 0 ? rest : undefined - })() - - if (isNew || !node.id) { - const { id: _id, ...rest } = node - const committed = DormerNodeSchema.parse({ - ...rest, - roofSegmentId: hit.segment.id, - parentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], - rotation, - metadata: cleanedMeta, - }) - state.createNode(committed, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - setSelection({ selectedIds: [committed.id] }) - } else { - const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined - state.updateNode(node.id as AnyNodeId, { - roofSegmentId: hit.segment.id, - parentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], - rotation, - metadata: cleanedMeta, - }) - if (prevSegmentId) state.dirtyNodes.add(prevSegmentId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - // Unlist from previous segment's children and add to the new one. - if (prevSegmentId && prevSegmentId !== (hit.segment.id as AnyNodeId)) { - const prevSeg = state.nodes[prevSegmentId] as RoofSegmentNode | undefined - if (prevSeg) { - state.updateNode(prevSegmentId, { - children: (prevSeg.children ?? []).filter((id) => id !== node.id), - }) + // Strip the `isNew` / `isTransient` flags — only used to mark a + // clone or in-flight move that hasn't been committed yet. + const cleanedMeta = (() => { + const m = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + const { + isNew: _isNew, + isTransient: _isTransient, + ...rest + } = m as { + isNew?: boolean + isTransient?: boolean } - const newSeg = state.nodes[hit.segment.id as AnyNodeId] as RoofSegmentNode | undefined - if (newSeg && !(newSeg.children ?? []).includes(node.id)) { - state.updateNode(hit.segment.id as AnyNodeId, { - children: [...(newSeg.children ?? []), node.id], - }) + return Object.keys(rest).length > 0 ? rest : undefined + })() + + if (isNew || !node.id) { + const { id: _id, ...rest } = node + const committed = DormerNodeSchema.parse({ + ...rest, + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + position: [hit.localX, hit.localY, hit.localZ], + rotation, + metadata: cleanedMeta, + }) + state.createNode(committed, hit.segment.id as AnyNodeId) + state.dirtyNodes.add(hit.segment.id as AnyNodeId) + setSelection({ selectedIds: [committed.id] }) + } else { + const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined + state.updateNode(node.id as AnyNodeId, { + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + position: [hit.localX, hit.localY, hit.localZ], + rotation, + metadata: cleanedMeta, + }) + if (prevSegmentId) state.dirtyNodes.add(prevSegmentId) + state.dirtyNodes.add(hit.segment.id as AnyNodeId) + // Unlist from previous segment's children and add to the new one. + if (prevSegmentId && prevSegmentId !== (hit.segment.id as AnyNodeId)) { + const prevSeg = state.nodes[prevSegmentId] as RoofSegmentNode | undefined + if (prevSeg) { + state.updateNode(prevSegmentId, { + children: (prevSeg.children ?? []).filter((id) => id !== node.id), + }) + } + const newSeg = state.nodes[hit.segment.id as AnyNodeId] as RoofSegmentNode | undefined + if (newSeg && !(newSeg.children ?? []).includes(node.id)) { + state.updateNode(hit.segment.id as AnyNodeId, { + children: [...(newSeg.children ?? []), node.id], + }) + } } + setSelection({ selectedIds: [node.id] }) } - setSelection({ selectedIds: [node.id] }) - } - const dormerObj = sceneRegistry.nodes.get(node.id) - if (dormerObj) dormerObj.visible = true - setMovingNode(null) - }, - }) + const dormerObj = sceneRegistry.nodes.get(node.id) + if (dormerObj) dormerObj.visible = true + setMovingNode(null) + }, + }) if (!activeBuildingId || !segmentXform || !hitLocal) return null return ( + {hitSegment && ( + + )} diff --git a/packages/nodes/src/dormer/placement-guides.tsx b/packages/nodes/src/dormer/placement-guides.tsx new file mode 100644 index 000000000..8d5a35467 --- /dev/null +++ b/packages/nodes/src/dormer/placement-guides.tsx @@ -0,0 +1,153 @@ +'use client' + +import type { RoofSegmentNode } from '@pascal-app/core' +import { EDITOR_LAYER, formatMeasurement } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useMemo } from 'react' +import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three' +import { LineBasicNodeMaterial } from 'three/webgpu' +import { getRoofSurfaceFaceBoundsAt } from '../shared/roof-surface' + +// Indigo — matches the wall/window 3D proximity guide accent so every +// "distance to edge" readout reads the same across the app. +const GUIDE_COLOR = 0x81_8c_f8 +const PILL_BG = '#6366f1' +// Lift the lines a hair off the sloped surface so they don't z-fight the +// roof + dormer ghost. +const SURFACE_LIFT = 0.02 +// Hide a gap that has collapsed (dormer edge flush to / past the roof edge) +// so we don't draw a degenerate "0m" pill. +const MIN_GAP_M = 0.02 + +const guideMaterial = new LineBasicNodeMaterial({ + color: GUIDE_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, +}) + +type Vec3 = [number, number, number] + +/** + * Live "distance to roof edge" guides shown while a dormer ghost is being + * placed or dragged — the roof-plane analog of the window's sill/head + + * edge-proximity pills. Renders measured lines from each side-center of + * the dormer's occupied roof area out to the active roof face edges, each + * with a distance pill at its midpoint. + * + * Mounted as a sibling of `` INSIDE the segment-local frame + * (the `segmentXform` group) but OUTSIDE the dormer's `hitLocal` + rotation + * groups, so its coordinates are segment-local. The roof-face boundary is + * resolved from the actual visible top face under `center`, not from the + * wall footprint dimensions. + * + * Normal roof accessories use side-center readouts. Linear accessories + * like ridge vents and gutters use their own two-end guide mode. + */ +export function DormerPlacementGuides({ + segment, + center, + width, + depth, + rotation, +}: { + segment: RoofSegmentNode + center: Vec3 + width: number + depth: number + rotation: number +}) { + const unit = useViewer((s) => s.unit) + + const [cx, , cz] = center + const faceBounds = getRoofSurfaceFaceBoundsAt(segment, cx, cz) + const halfW = Math.max(0, width) / 2 + const halfD = Math.max(0, depth) / 2 + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + const halfX = Math.abs(cos) * halfW + Math.abs(sin) * halfD + const halfZ = Math.abs(sin) * halfW + Math.abs(cos) * halfD + + const surfaceY = (x: number, z: number): number => faceBounds.surfaceYAt(x, z) + SURFACE_LIFT + + const xInterval = faceBounds.xIntervalAtZ(cz) + const zInterval = faceBounds.zIntervalAtX(cx) + + const guides: { id: string; from: Vec3; to: Vec3; value: number }[] = [] + const push = (id: string, ax: number, az: number, bx: number, bz: number) => { + const from: Vec3 = [ax, surfaceY(ax, az), az] + const to: Vec3 = [bx, surfaceY(bx, bz), bz] + const value = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2]) + if (value < MIN_GAP_M) return + guides.push({ + id, + from, + to, + value, + }) + } + if (xInterval) { + const [faceMinX, faceMaxX] = xInterval + const itemMinX = Math.max(faceMinX, Math.min(faceMaxX, cx - halfX)) + const itemMaxX = Math.max(faceMinX, Math.min(faceMaxX, cx + halfX)) + if (itemMinX > faceMinX + MIN_GAP_M) push('left', faceMinX, cz, itemMinX, cz) + if (itemMaxX < faceMaxX - MIN_GAP_M) push('right', itemMaxX, cz, faceMaxX, cz) + } + if (zInterval) { + const [faceMinZ, faceMaxZ] = zInterval + const itemMinZ = Math.max(faceMinZ, Math.min(faceMaxZ, cz - halfZ)) + const itemMaxZ = Math.max(faceMinZ, Math.min(faceMaxZ, cz + halfZ)) + if (itemMinZ > faceMinZ + MIN_GAP_M) push('back', cx, faceMinZ, cx, itemMinZ) + if (itemMaxZ < faceMaxZ - MIN_GAP_M) push('front', cx, itemMaxZ, cx, faceMaxZ) + } + + return ( + <> + {guides.map((g) => ( + + ))} + + ) +} + +function GuideLine({ from, to, pill }: { from: Vec3; to: Vec3; pill: string }) { + const { line, position } = useMemo(() => { + const position = new Float32BufferAttribute(new Float32Array(6), 3) + const geometry = new BufferGeometry() + geometry.setAttribute('position', position) + const line = new ThreeLine(geometry, guideMaterial) + line.frustumCulled = false + line.layers.set(EDITOR_LAYER) + line.renderOrder = 1000 + return { line, position } + }, []) + + position.setXYZ(0, from[0], from[1], from[2]) + position.setXYZ(1, to[0], to[1], to[2]) + position.needsUpdate = true + + useEffect(() => () => line.geometry.dispose(), [line]) + + const mid: Vec3 = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2, (from[2] + to[2]) / 2] + + return ( + <> + + +
+ {pill} +
+ + + ) +} diff --git a/packages/nodes/src/dormer/tool.tsx b/packages/nodes/src/dormer/tool.tsx index d677c6a53..9b23c1cff 100644 --- a/packages/nodes/src/dormer/tool.tsx +++ b/packages/nodes/src/dormer/tool.tsx @@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { dormerDefinition } from './definition' +import { DormerPlacementGuides } from './placement-guides' import DormerPreview from './preview' import { useDormerPlacement } from './use-dormer-placement' @@ -48,7 +49,7 @@ const DormerTool = () => { [], ) - const { activeBuildingId, clearPreview, segmentXform, hitLocal, ghostRotation } = + const { activeBuildingId, clearPreview, segmentXform, hitSegment, hitLocal, ghostRotation } = useDormerPlacement({ onCommit: (hit, rotation) => { const state = useScene.getState() @@ -78,6 +79,15 @@ const DormerTool = () => { /> {activeBuildingId && segmentXform && hitLocal && ( + {hitSegment && ( + + )} diff --git a/packages/nodes/src/dormer/use-dormer-placement.ts b/packages/nodes/src/dormer/use-dormer-placement.ts index 7488b2dc5..52d0e03e4 100644 --- a/packages/nodes/src/dormer/use-dormer-placement.ts +++ b/packages/nodes/src/dormer/use-dormer-placement.ts @@ -60,12 +60,14 @@ export function useDormerPlacement(opts: { activeBuildingId: string | undefined clearPreview: () => void segmentXform: DormerSegmentTransform | null + hitSegment: RoofSegmentNode | null hitLocal: [number, number, number] | null ghostRotation: number } { const activeBuildingId = useViewer((s) => s.selection.buildingId) const [segmentXform, setSegmentXform] = useState(null) + const [hitSegment, setHitSegment] = useState(null) const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null) const [ghostRotation, setGhostRotation] = useState(opts.initialRotation ?? 0) const lastSnapRef = useRef<[number, number] | null>(null) @@ -81,6 +83,7 @@ export function useDormerPlacement(opts: { const clearPreview = () => { setSegmentXform(null) + setHitSegment(null) setHitLocal(null) } @@ -136,6 +139,7 @@ export function useDormerPlacement(opts: { const xform = computeSegmentXform(hit.segment.id) if (!xform) return setSegmentXform(xform) + setHitSegment(hit.segment) // Lift the ghost to the actual roof-surface Y at the cursor so // it tracks the mouse along the slope. The CSG inside // `generateDormerGeometry` carves the dormer against the host @@ -200,6 +204,7 @@ export function useDormerPlacement(opts: { activeBuildingId: activeBuildingId ?? undefined, clearPreview, segmentXform, + hitSegment, hitLocal, ghostRotation, } diff --git a/packages/nodes/src/eyebrow-vent/tool.tsx b/packages/nodes/src/eyebrow-vent/tool.tsx index 93ccb5b97..303e48189 100644 --- a/packages/nodes/src/eyebrow-vent/tool.tsx +++ b/packages/nodes/src/eyebrow-vent/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { eyebrowVentDefinition } from './definition' import EyebrowVentPreview from './preview' @@ -80,6 +85,15 @@ const EyebrowVentTool = () => { setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode({ + ...previewNode, + rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), + }), + }) event.stopPropagation() } @@ -104,6 +118,7 @@ const EyebrowVentTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [vent.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -115,8 +130,9 @@ const EyebrowVentTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -126,6 +142,7 @@ const EyebrowVentTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx index 948d4febb..c4e08b542 100644 --- a/packages/nodes/src/gutter/tool.tsx +++ b/packages/nodes/src/gutter/tool.tsx @@ -13,6 +13,11 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { gutterDefinition } from './definition' import { type EaveSnap, resolveEaveSnap } from './eave-snap' import GutterPreview from './preview' @@ -100,6 +105,13 @@ const GutterTool = () => { }, snap, }) + publishRoofSurfacePlacementGuides({ + roof, + segment: hit.segment, + center: [snap.eaveX, snap.eaveY, snap.eaveZ], + footprint: roofSurfaceFootprintFromNode({ ...previewNode, rotation: snap.rotation }), + mode: 'linear-edge', + }) event.stopPropagation() } @@ -129,6 +141,7 @@ const GutterTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [gutter.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -140,15 +153,19 @@ const GutterTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> } - onInvalidTarget={() => setTarget(null)} + onInvalidTarget={() => { + setTarget(null) + clearRoofSurfacePlacementGuides() + }} /> {activeBuildingId && target && ( diff --git a/packages/nodes/src/ridge-vent/tool.tsx b/packages/nodes/src/ridge-vent/tool.tsx index f73693556..228320629 100644 --- a/packages/nodes/src/ridge-vent/tool.tsx +++ b/packages/nodes/src/ridge-vent/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { resolveRidgeSnap } from '../shared/ridge-snap' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { ridgeVentDefinition } from './definition' import RidgeVentPreview from './preview' @@ -73,6 +78,7 @@ const RidgeVentTool = () => { const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) if (!snap) { setPreviewPos(null) + clearRoofSurfacePlacementGuides() return } const segObj = sceneRegistry.nodes.get(hit.segment.id) @@ -96,6 +102,13 @@ const RidgeVentTool = () => { setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewPos(worldToBuildingLocal(ridgeWorld[0], ridgeWorld[1], ridgeWorld[2])) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [snap.localX, hit.localY, snap.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode), + mode: 'linear-edge', + }) event.stopPropagation() } @@ -122,6 +135,7 @@ const RidgeVentTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [vent.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -133,8 +147,9 @@ const RidgeVentTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -150,7 +165,10 @@ const RidgeVentTool = () => { ) return !!hit && !!resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) }} - onInvalidTarget={() => setPreviewPos(null)} + onInvalidTarget={() => { + setPreviewPos(null) + clearRoofSurfacePlacementGuides() + }} /> {activeBuildingId && previewPos && ( diff --git a/packages/nodes/src/shared/roof-surface-placement-guides.ts b/packages/nodes/src/shared/roof-surface-placement-guides.ts new file mode 100644 index 000000000..8c2c8c80f --- /dev/null +++ b/packages/nodes/src/shared/roof-surface-placement-guides.ts @@ -0,0 +1,292 @@ +import { + type AnyNodeId, + type RoofNode, + type RoofSegmentNode, + sceneRegistry, +} from '@pascal-app/core' +import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import * as THREE from 'three' +import { buildBoxVentGeometry } from '../box-vent/geometry' +import { buildChimneyGeometry } from '../chimney/geometry' +import { buildCupolaGeometry } from '../cupola/geometry' +import { buildDormerGhostGeometry } from '../dormer/geometry' +import { buildEyebrowVentGeometry } from '../eyebrow-vent/geometry' +import { buildGutterGeometry } from '../gutter/geometry' +import { buildRidgeVentGeometry } from '../ridge-vent/geometry' +import { buildFrameGeometry } from '../skylight/frame-csg' +import { buildSolarPanelGeometry } from '../solar-panel/geometry' +import { buildTurbineVentGeometry } from '../turbine-vent/geometry' +import { getRoofSurfaceFaceBoundsAt } from './roof-surface' + +const MIN_DIMENSION_M = 0.02 + +const tmp = new THREE.Vector3() +const tmpA = new THREE.Vector3() +const tmpB = new THREE.Vector3() + +export type RoofSurfaceGuideMode = 'side-center' | 'linear-edge' + +export type RoofSurfaceGuideFootprint = { + width: number + depth: number + rotation?: number +} + +export function roofSurfaceFootprintFromNode( + node: unknown, + options?: { segment?: RoofSegmentNode }, +): RoofSurfaceGuideFootprint { + const n = node as Record + const geometryBounds = geometryFootprintForNode(n, options?.segment) + if (geometryBounds) { + return { + ...geometryBounds, + rotation: numberField(n.rotation, 0), + } + } + + if (n.type === 'solar-panel') { + const columns = numberField(n.columns, 1) + const rows = numberField(n.rows, 1) + const panelWidth = numberField(n.panelWidth, 1) + const panelHeight = numberField(n.panelHeight, 1) + const gapX = numberField(n.gapX, 0) + const gapY = numberField(n.gapY, 0) + return { + width: columns * panelWidth + Math.max(0, columns - 1) * gapX, + depth: rows * panelHeight + Math.max(0, rows - 1) * gapY, + rotation: numberField(n.rotation, 0), + } + } + + if (n.type === 'ridge-vent') { + return { + width: numberField(n.length, 1), + depth: numberField(n.width, 0.3), + rotation: numberField(n.rotation, 0), + } + } + + if (n.type === 'gutter') { + return { + width: numberField(n.length, 1), + depth: numberField(n.size, 0.13), + rotation: numberField(n.rotation, 0), + } + } + + const width = numberField(n.width, numberField(n.diameter, 1)) + const depth = numberField(n.depth, width) + return { + width, + depth, + rotation: numberField(n.rotation, 0), + } +} + +function geometryFootprintForNode( + node: Record, + segment: RoofSegmentNode | undefined, +): Pick | null { + const bounds = new THREE.Box3() + const geometries: THREE.BufferGeometry[] = [] + const add = (geometry: THREE.BufferGeometry | null | undefined) => { + if (geometry) geometries.push(geometry) + } + + try { + switch (node.type) { + case 'box-vent': + add(buildBoxVentGeometry(node as Parameters[0])) + break + case 'turbine-vent': + add(buildTurbineVentGeometry(node as Parameters[0])) + break + case 'eyebrow-vent': + add(buildEyebrowVentGeometry(node as Parameters[0])) + break + case 'solar-panel': + add(buildSolarPanelGeometry(node as Parameters[0])) + break + case 'skylight': + add( + buildFrameGeometry({ + curb: node.curb as never, + curbHeight: node.curbHeight as never, + frameDepth: node.frameDepth as never, + frameThickness: node.frameThickness as never, + height: node.height as never, + width: node.width as never, + }), + ) + add(buildSkylightGlassBounds(node)) + break + case 'cupola': + add(buildCupolaGeometry(node as Parameters[0])) + break + case 'chimney': + if (segment) { + const geo = buildChimneyGeometry( + node as Parameters[0], + segment, + ) + add(geo.body) + add(geo.cap) + add(geo.flues) + add(geo.cricket) + add(geo.bands) + } + break + case 'ridge-vent': + add(buildRidgeVentGeometry(node as Parameters[0])) + break + case 'gutter': + add(buildGutterGeometry(node as Parameters[0])) + break + case 'dormer': + add(buildDormerGhostGeometry(node as Parameters[0])) + break + } + + if (geometries.length === 0) return null + bounds.makeEmpty() + for (const geometry of geometries) { + geometry.computeBoundingBox() + if (geometry.boundingBox) bounds.union(geometry.boundingBox) + } + if (bounds.isEmpty()) return null + return { + width: Math.max(0, bounds.max.x - bounds.min.x), + depth: Math.max(0, bounds.max.z - bounds.min.z), + } + } catch { + return null + } finally { + for (const geometry of geometries) geometry.dispose() + } +} + +function buildSkylightGlassBounds(node: Record): THREE.BufferGeometry { + const width = numberField(node.width, 1) + const height = numberField(node.height, 1) + const glassThickness = numberField(node.glassThickness, 0.01) + const curbHeight = node.curb ? Math.max(0, numberField(node.curbHeight, 0.1)) : 0 + const geometry = new THREE.BoxGeometry(width, glassThickness, height) + geometry.translate(0, curbHeight + glassThickness / 2, 0) + return geometry +} + +export function publishRoofSurfacePlacementGuides(args: { + roof: RoofNode + segment: RoofSegmentNode + center: readonly [number, number, number] + footprint: RoofSurfaceGuideFootprint + mode?: RoofSurfaceGuideMode +}): void { + const { segment, center, footprint, mode = 'side-center' } = args + const segObj = sceneRegistry.nodes.get(segment.id as AnyNodeId) + if (!segObj) return + + const halfW = Math.max(0, footprint.width) / 2 + const halfD = Math.max(0, footprint.depth) / 2 + const rot = footprint.rotation ?? 0 + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const halfX = Math.abs(cos) * halfW + Math.abs(sin) * halfD + const halfZ = Math.abs(sin) * halfW + Math.abs(cos) * halfD + + const faceBounds = getRoofSurfaceFaceBoundsAt(segment, center[0], center[2]) + + const toBuilding = (x: number, z: number): [number, number, number] => { + const y = faceBounds.surfaceYAt(x, z) + 0.035 + tmp.set(x, y, z) + segObj.localToWorld(tmp) + const buildingId = useViewer.getState().selection.buildingId + const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + if (buildingObj) buildingObj.worldToLocal(tmp) + return [tmp.x, tmp.y, tmp.z] + } + + const dimension = ( + id: string, + from: [number, number], + to: [number, number], + ): OpeningGuide3D | null => { + const from3 = toBuilding(from[0], from[1]) + const to3 = toBuilding(to[0], to[1]) + const value = tmpA.set(...from3).distanceTo(tmpB.set(...to3)) + if (value <= MIN_DIMENSION_M) return null + return { + kind: 'dimension', + id, + from: from3, + to: to3, + value, + } + } + + const guides: OpeningGuide3D[] = [] + + if (mode === 'linear-edge') { + const useX = Math.abs(cos) >= Math.abs(sin) + if (useX) { + const interval = faceBounds.xIntervalAtZ(center[2]) + if (interval) { + const [faceMinX, faceMaxX] = interval + const startX = clamp(center[0] - halfW, faceMinX, faceMaxX) + const endX = clamp(center[0] + halfW, faceMinX, faceMaxX) + const left = dimension('roof-gap:left', [faceMinX, center[2]], [startX, center[2]]) + const right = dimension('roof-gap:right', [endX, center[2]], [faceMaxX, center[2]]) + if (left) guides.push(left) + if (right) guides.push(right) + } + } else { + const interval = faceBounds.zIntervalAtX(center[0]) + if (interval) { + const [faceMinZ, faceMaxZ] = interval + const startZ = clamp(center[2] - halfW, faceMinZ, faceMaxZ) + const endZ = clamp(center[2] + halfW, faceMinZ, faceMaxZ) + const bottom = dimension('roof-gap:bottom', [center[0], faceMinZ], [center[0], startZ]) + const top = dimension('roof-gap:top', [center[0], endZ], [center[0], faceMaxZ]) + if (bottom) guides.push(bottom) + if (top) guides.push(top) + } + } + } else { + const xInterval = faceBounds.xIntervalAtZ(center[2]) + const zInterval = faceBounds.zIntervalAtX(center[0]) + if (xInterval) { + const [faceMinX, faceMaxX] = xInterval + const itemMinX = clamp(center[0] - halfX, faceMinX, faceMaxX) + const itemMaxX = clamp(center[0] + halfX, faceMinX, faceMaxX) + const left = dimension('roof-gap:left', [faceMinX, center[2]], [itemMinX, center[2]]) + const right = dimension('roof-gap:right', [itemMaxX, center[2]], [faceMaxX, center[2]]) + if (left) guides.push(left) + if (right) guides.push(right) + } + if (zInterval) { + const [faceMinZ, faceMaxZ] = zInterval + const itemMinZ = clamp(center[2] - halfZ, faceMinZ, faceMaxZ) + const itemMaxZ = clamp(center[2] + halfZ, faceMinZ, faceMaxZ) + const bottom = dimension('roof-gap:bottom', [center[0], faceMinZ], [center[0], itemMinZ]) + const top = dimension('roof-gap:top', [center[0], itemMaxZ], [center[0], faceMaxZ]) + if (bottom) guides.push(bottom) + if (top) guides.push(top) + } + } + + useOpeningGuides.getState().set(guides) +} + +export function clearRoofSurfacePlacementGuides(): void { + useOpeningGuides.getState().clear() +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function numberField(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} diff --git a/packages/nodes/src/shared/roof-surface.test.ts b/packages/nodes/src/shared/roof-surface.test.ts index 1b5157df5..83cd0b335 100644 --- a/packages/nodes/src/shared/roof-surface.test.ts +++ b/packages/nodes/src/shared/roof-surface.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { RoofSegmentNode } from '@pascal-app/core' -import { getDownSlopeYaw } from './roof-surface' +import { getDownSlopeYaw, getRoofSurfaceFaceBoundsAt, getSurfaceY } from './roof-surface' const fixtureSegment = (overrides?: Partial): RoofSegmentNode => ({ @@ -41,3 +41,26 @@ describe('getDownSlopeYaw', () => { expect(getDownSlopeYaw(0, 0, fixtureSegment({ roofType: 'flat' }))).toBe(0) }) }) + +describe('getRoofSurfaceFaceBoundsAt', () => { + test('gable face bounds use the visible shingle face, not the wall footprint', () => { + const segment = fixtureSegment() + const bounds = getRoofSurfaceFaceBoundsAt(segment, 0, 1) + const xInterval = bounds.xIntervalAtZ(1) + const zInterval = bounds.zIntervalAtX(0) + + expect(xInterval?.[0]).toBeLessThan(-segment.width / 2) + expect(xInterval?.[1]).toBeGreaterThan(segment.width / 2) + expect(zInterval?.[0]).toBeCloseTo(0) + expect(zInterval?.[1]).toBeGreaterThan(segment.depth / 2) + expect(bounds.surfaceYAt(0, 1)).toBeGreaterThan(getSurfaceY(0, 1, segment)) + }) + + test('hip face bounds shrink guide endpoints to the active triangular face edge', () => { + const bounds = getRoofSurfaceFaceBoundsAt(fixtureSegment({ roofType: 'hip' }), 0, 1) + const ridgeInterval = bounds.xIntervalAtZ(0) + + expect(ridgeInterval?.[0]).toBeGreaterThan(-2) + expect(ridgeInterval?.[1]).toBeLessThan(2) + }) +}) diff --git a/packages/nodes/src/shared/roof-surface.ts b/packages/nodes/src/shared/roof-surface.ts index f60278cd0..033b293f1 100644 --- a/packages/nodes/src/shared/roof-surface.ts +++ b/packages/nodes/src/shared/roof-surface.ts @@ -3,6 +3,7 @@ import { getSegmentSlopeFrame, ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, + type RoofType, } from '@pascal-app/core' import * as THREE from 'three' @@ -16,6 +17,510 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe return getRoofSegmentSurfaceY(seg, lx, lz) } +export type RoofSurfacePoint2D = [number, number] + +export type RoofSurfaceFaceBounds = { + polygon: RoofSurfacePoint2D[] + minX: number + maxX: number + minZ: number + maxZ: number + surfaceYAt: (x: number, z: number) => number + xIntervalAtZ: (z: number) => [number, number] | null + zIntervalAtX: (x: number) => [number, number] | null +} + +export function getRoofSurfaceFaceBoundsAt( + segment: RoofSegmentNode, + lx: number, + lz: number, +): RoofSurfaceFaceBounds { + const faces = getRoofSurfaceFaces(segment) + const face = + faces.find((candidate) => pointInPolygon([lx, lz], candidate.polygon)) ?? + nearestFaceToPoint(faces, [lx, lz]) + const { polygon } = face + + const xs = polygon.map((point) => point[0]) + const zs = polygon.map((point) => point[1]) + return { + polygon, + minX: Math.min(...xs), + maxX: Math.max(...xs), + minZ: Math.min(...zs), + maxZ: Math.max(...zs), + surfaceYAt: (x, z) => + surfaceYOnFace(face.vertices, x, z) ?? getRoofSegmentSurfaceY(segment, x, z), + xIntervalAtZ: (z) => lineInterval(polygon, 'x', z), + zIntervalAtX: (x) => lineInterval(polygon, 'z', x), + } +} + +type RoofSurfaceFace = { + polygon: RoofSurfacePoint2D[] + vertices: FaceVertex[] +} +type FaceVertex = { x: number; y: number; z: number } +type FaceInsets = { + iF?: number + iB?: number + iL?: number + iR?: number + dutchI?: number +} +type FaceShapeRatios = { + gambrelLowerWidthRatio: number + mansardSteepWidthRatio: number + dutchHipWidthRatio: number +} + +const SHINGLE_SURFACE_EPSILON = 0.02 +const FACE_TOLERANCE = 1e-6 + +function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { + const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang } = segment + const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment) + + const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness + const horizontalOverhang = (overhang ?? 0) * cosTheta + const deckExt = wallThickness / 2 + horizontalOverhang + const shingleThickness = segment.shingleThickness ?? 0 + const stSin = shingleThickness * sinTheta + const stCos = shingleThickness * cosTheta + + const shinBotW = Math.max(0.01, width + 2 * deckExt) + const shinBotD = Math.max(0.01, depth + 2 * deckExt) + const deckDrop = deckExt * tanTheta + const shinBotWh = wallHeight - deckDrop + verticalRt + + let shinBotRh = activeRh + if (activeRh > 0) { + shinBotRh = activeRh + deckDrop + if (roofType === 'shed') shinBotRh = activeRh + 2 * deckDrop + } + + let shinTopW = shinBotW + let shinTopD = shinBotD + let transZ = 0 + + if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') { + shinTopW += 2 * stSin + shinTopD += 2 * stSin + } else if (roofType === 'gable' || roofType === 'gambrel') { + shinTopD += 2 * stSin + } else if (roofType === 'shed') { + shinTopD += stSin + transZ = stSin / 2 + } + + const shinTopWh = shinBotWh + stCos + let shinTopRh = shinBotRh + if (activeRh > 0) shinTopRh = shinBotRh + stSin * tanTheta + + const availableR = (Math.min(shinBotW, shinBotD) / 2) * 0.95 + const maxDrop = tanTheta > 0.001 ? availableR / tanTheta : 2 + const dropTop = Math.min(1, maxDrop * 0.4) + const topBaseY = shinBotWh - dropTop + + const insetsTop = getRoofFaceInsets( + roofType, + width, + depth, + shinTopWh, + topBaseY, + false, + shinTopW, + shinTopD, + tanTheta, + shingleThickness, + ) + const shapeRatios = { + gambrelLowerWidthRatio: + segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio, + mansardSteepWidthRatio: + segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio, + dutchHipWidthRatio: segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, + } + + return getRoofModuleFaces( + roofType, + shinTopW, + shinTopD, + shinTopWh, + shinTopRh, + topBaseY, + insetsTop, + width, + depth, + tanTheta, + shapeRatios, + ) + .filter((face) => faceNormalY(face) > SHINGLE_SURFACE_EPSILON) + .map((face) => { + const vertices = face.map((point) => ({ ...point, z: point.z + transZ })) + return { + vertices, + polygon: dedupePolygon(vertices.map((point) => [point.x, point.z])), + } + }) + .filter((face) => face.polygon.length >= 3) +} + +function getRoofFaceInsets( + roofType: RoofType, + width: number, + depth: number, + wh: number, + baseY: number, + isVoid: boolean, + brushW: number, + brushD: number, + tanTheta: number, + shingleThickness: number, +): FaceInsets { + let inset = (wh - baseY) * tanTheta + const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005 + if (inset > maxSafeInset) inset = maxSafeInset + + let iF = 0 + let iB = 0 + let iL = 0 + let iR = 0 + if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') { + iF = inset + iB = inset + iL = inset + iR = inset + } else if (roofType === 'gable' || roofType === 'gambrel') { + iF = inset + iB = inset + } else if (roofType === 'shed') { + iF = inset + } + + let dutchI = Math.min(width, depth) * 0.25 + if (isVoid) dutchI += shingleThickness + return { iF, iB, iL, iR, dutchI } +} + +function getRoofModuleFaces( + type: RoofType, + w: number, + d: number, + wh: number, + rh: number, + baseY: number, + insets: FaceInsets, + baseW: number, + baseD: number, + tanTheta: number, + shapeRatios: FaceShapeRatios, +): FaceVertex[][] { + const v = (x: number, y: number, z: number): FaceVertex => ({ x, y, z }) + const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets + + const b1 = v(-w / 2 + iL, baseY, d / 2 - iF) + const b2 = v(w / 2 - iR, baseY, d / 2 - iF) + const b3 = v(w / 2 - iR, baseY, -d / 2 + iB) + const b4 = v(-w / 2 + iL, baseY, -d / 2 + iB) + const bottom = [b4, b3, b2, b1] + + const e1 = v(-w / 2, wh, d / 2) + const e2 = v(w / 2, wh, d / 2) + const e3 = v(w / 2, wh, -d / 2) + const e4 = v(-w / 2, wh, -d / 2) + + const faces: FaceVertex[][] = [] + faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom) + + const h = wh + Math.max(0.001, rh) + + if (type === 'flat' || rh === 0) { + faces.push([e1, e2, e3, e4]) + } else if (type === 'gable') { + const r1 = v(-w / 2, h, 0) + const r2 = v(w / 2, h, 0) + faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) + } else if (type === 'hip') { + if (Math.abs(w - d) < 0.01) { + const r = v(0, h, 0) + faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r]) + } else if (w >= d) { + const r1 = v(-w / 2 + d / 2, h, 0) + const r2 = v(w / 2 - d / 2, h, 0) + faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) + } else { + const r1 = v(0, h, d / 2 - w / 2) + const r2 = v(0, h, -d / 2 + w / 2) + faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) + } + } else if (type === 'shed') { + const t1 = v(-w / 2, h, -d / 2) + const t2 = v(w / 2, h, -d / 2) + faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1]) + } else if (type === 'gambrel') { + const mz = (baseD / 2) * shapeRatios.gambrelLowerWidthRatio + const dist = d / 2 - mz + const mh = wh + dist * (tanTheta || 0) + + const m1 = v(-w / 2, mh, mz) + const m2 = v(w / 2, mh, mz) + const m3 = v(w / 2, mh, -mz) + const m4 = v(-w / 2, mh, -mz) + const r1 = v(-w / 2, h, 0) + const r2 = v(w / 2, h, 0) + faces.push( + [e4, e1, m1, r1, m4], + [e2, e3, m3, r2, m2], + [e1, e2, m2, m1], + [m1, m2, r2, r1], + [e3, e4, m4, m3], + [m3, m4, r1, r2], + ) + } else if (type === 'mansard') { + const i = Math.min(baseW, baseD) * shapeRatios.mansardSteepWidthRatio + const mh = wh + i * (tanTheta || 0) + + const m1 = v(-w / 2 + i, mh, d / 2 - i) + const m2 = v(w / 2 - i, mh, d / 2 - i) + const m3 = v(w / 2 - i, mh, -d / 2 + i) + const m4 = v(-w / 2 + i, mh, -d / 2 + i) + const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2) + const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2) + const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2) + const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2) + if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) { + if (w >= d) { + const r1 = v(-w / 2 + d / 2, h, 0) + const r2 = v(w / 2 - d / 2, h, 0) + faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) + } else { + const r1 = v(0, h, d / 2 - w / 2) + const r2 = v(0, h, -d / 2 + w / 2) + faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) + } + } else { + faces.push( + [t1, t2, t3, t4], + [e1, e2, m2, m1], + [e2, e3, m3, m2], + [e3, e4, m4, m3], + [e4, e1, m1, m4], + [m1, m2, t2, t1], + [m2, m3, t3, t2], + [m3, m4, t4, t3], + [m4, m1, t1, t4], + ) + } + } else if (type === 'dutch') { + const i = + insets.dutchI !== undefined + ? insets.dutchI + : Math.min(baseW, baseD) * shapeRatios.dutchHipWidthRatio + const mh = wh + i * (tanTheta || 0) + + if (w >= d) { + const m1 = v(-w / 2 + i, mh, d / 2 - i) + const m2 = v(w / 2 - i, mh, d / 2 - i) + const m3 = v(w / 2 - i, mh, -d / 2 + i) + const m4 = v(-w / 2 + i, mh, -d / 2 + i) + const r1 = v(-w / 2 + i, h, 0) + const r2 = v(w / 2 - i, h, 0) + + faces.push( + [e1, e2, m2, m1], + [e2, e3, m3, m2], + [e3, e4, m4, m3], + [e4, e1, m1, m4], + [m4, m1, r1], + [m2, m3, r2], + [m1, m2, r2, r1], + [m3, m4, r1, r2], + ) + } else { + const m1 = v(-w / 2 + i, mh, d / 2 - i) + const m2 = v(w / 2 - i, mh, d / 2 - i) + const m3 = v(w / 2 - i, mh, -d / 2 + i) + const m4 = v(-w / 2 + i, mh, -d / 2 + i) + const r1 = v(0, h, d / 2 - i) + const r2 = v(0, h, -d / 2 + i) + + faces.push( + [e1, e2, m2, m1], + [e2, e3, m3, m2], + [e3, e4, m4, m3], + [e4, e1, m1, m4], + [m1, m2, r1], + [m3, m4, r2], + [m2, m3, r2, r1], + [m4, m1, r1, r2], + ) + } + } + + return faces +} + +function faceNormalY(face: FaceVertex[]): number { + const a = face[0] + const b = face[1] + const c = face[2] + if (!(a && b && c)) return 0 + const abx = b.x - a.x + const aby = b.y - a.y + const abz = b.z - a.z + const acx = c.x - a.x + const acy = c.y - a.y + const acz = c.z - a.z + return abz * acx - abx * acz +} + +function dedupePolygon(points: RoofSurfacePoint2D[]): RoofSurfacePoint2D[] { + const out: RoofSurfacePoint2D[] = [] + for (const point of points) { + const prev = out.at(-1) + if (prev && Math.hypot(prev[0] - point[0], prev[1] - point[1]) <= FACE_TOLERANCE) continue + out.push(point) + } + const first = out[0] + const last = out.at(-1) + if (first && last && Math.hypot(first[0] - last[0], first[1] - last[1]) <= FACE_TOLERANCE) { + out.pop() + } + return out +} + +function pointInPolygon(point: RoofSurfacePoint2D, polygon: RoofSurfacePoint2D[]): boolean { + let inside = false + const [px, pz] = point + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, zi] = polygon[i]! + const [xj, zj] = polygon[j]! + if (pointOnSegment(point, [xi, zi], [xj, zj])) return true + const intersects = zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi + if (intersects) inside = !inside + } + return inside +} + +function pointOnSegment( + point: RoofSurfacePoint2D, + a: RoofSurfacePoint2D, + b: RoofSurfacePoint2D, +): boolean { + const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]) + if (Math.abs(cross) > FACE_TOLERANCE) return false + const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]) + if (dot < -FACE_TOLERANCE) return false + const lengthSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 + return dot <= lengthSq + FACE_TOLERANCE +} + +function nearestFaceToPoint(faces: RoofSurfaceFace[], point: RoofSurfacePoint2D): RoofSurfaceFace { + let best = faces[0] + let bestDistance = Number.POSITIVE_INFINITY + for (const face of faces) { + const distance = distanceToPolygon(point, face.polygon) + if (distance < bestDistance) { + best = face + bestDistance = distance + } + } + return ( + best ?? { + polygon: [ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + ], + vertices: [ + { x: -0.5, y: 0, z: -0.5 }, + { x: 0.5, y: 0, z: -0.5 }, + { x: 0.5, y: 0, z: 0.5 }, + { x: -0.5, y: 0, z: 0.5 }, + ], + } + ) +} + +function surfaceYOnFace(vertices: FaceVertex[], x: number, z: number): number | null { + for (let i = 0; i < vertices.length - 2; i++) { + const a = vertices[i] + const b = vertices[i + 1] + const c = vertices[i + 2] + if (!(a && b && c)) continue + const abx = b.x - a.x + const aby = b.y - a.y + const abz = b.z - a.z + const acx = c.x - a.x + const acy = c.y - a.y + const acz = c.z - a.z + const nx = aby * acz - abz * acy + const ny = abz * acx - abx * acz + const nz = abx * acy - aby * acx + if (Math.abs(ny) <= FACE_TOLERANCE) continue + return a.y - (nx * (x - a.x) + nz * (z - a.z)) / ny + } + return null +} + +function distanceToPolygon(point: RoofSurfacePoint2D, polygon: RoofSurfacePoint2D[]): number { + if (pointInPolygon(point, polygon)) return 0 + let best = Number.POSITIVE_INFINITY + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]! + const b = polygon[(i + 1) % polygon.length]! + best = Math.min(best, distanceToSegment(point, a, b)) + } + return best +} + +function distanceToSegment( + point: RoofSurfacePoint2D, + a: RoofSurfacePoint2D, + b: RoofSurfacePoint2D, +): number { + const abx = b[0] - a[0] + const abz = b[1] - a[1] + const lengthSq = abx * abx + abz * abz + if (lengthSq <= FACE_TOLERANCE) return Math.hypot(point[0] - a[0], point[1] - a[1]) + const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * abx + (point[1] - a[1]) * abz) / lengthSq)) + return Math.hypot(point[0] - (a[0] + abx * t), point[1] - (a[1] + abz * t)) +} + +function lineInterval( + polygon: RoofSurfacePoint2D[], + axis: 'x' | 'z', + value: number, +): [number, number] | null { + const hits: number[] = [] + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]! + const b = polygon[(i + 1) % polygon.length]! + const aFixed = axis === 'x' ? a[1] : a[0] + const bFixed = axis === 'x' ? b[1] : b[0] + const aVar = axis === 'x' ? a[0] : a[1] + const bVar = axis === 'x' ? b[0] : b[1] + + if (Math.abs(aFixed - value) <= FACE_TOLERANCE && Math.abs(bFixed - value) <= FACE_TOLERANCE) { + hits.push(aVar, bVar) + continue + } + if (value < Math.min(aFixed, bFixed) - FACE_TOLERANCE) continue + if (value > Math.max(aFixed, bFixed) + FACE_TOLERANCE) continue + if (Math.abs(aFixed - bFixed) <= FACE_TOLERANCE) continue + + const t = (value - aFixed) / (bFixed - aFixed) + if (t < -FACE_TOLERANCE || t > 1 + FACE_TOLERANCE) continue + hits.push(aVar + (bVar - aVar) * t) + } + + const unique = Array.from(new Set(hits.map((hit) => hit.toFixed(6)))).map(Number) + if (unique.length < 2) return null + return [Math.min(...unique), Math.max(...unique)] +} + // Outward normal for a roof surface tilting at angle θ in the horizontal // direction (dx, dz). Derivation: the surface tangent vectors are the // ridge axis (perpendicular to the fall line, horizontal) and the diff --git a/packages/nodes/src/skylight/tool.tsx b/packages/nodes/src/skylight/tool.tsx index 5d6b005c4..66de3abdd 100644 --- a/packages/nodes/src/skylight/tool.tsx +++ b/packages/nodes/src/skylight/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { skylightDefinition } from './definition' import SkylightPreview from './preview' @@ -72,6 +77,12 @@ const SkylightTool = () => { setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode), + }) event.stopPropagation() } @@ -96,6 +107,7 @@ const SkylightTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [skylight.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -107,8 +119,9 @@ const SkylightTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -118,6 +131,7 @@ const SkylightTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/solar-panel/tool.tsx b/packages/nodes/src/solar-panel/tool.tsx index f397b7edf..48144ae8e 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { solarPanelDefinition } from './definition' import SolarPanelPreview from './preview' @@ -85,6 +90,12 @@ const SolarPanelTool = () => { setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode), + }) event.stopPropagation() } @@ -117,6 +128,7 @@ const SolarPanelTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [panel.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -128,8 +140,9 @@ const SolarPanelTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -139,6 +152,7 @@ const SolarPanelTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/turbine-vent/tool.tsx b/packages/nodes/src/turbine-vent/tool.tsx index 9634e5e6f..0ecfdfb2a 100644 --- a/packages/nodes/src/turbine-vent/tool.tsx +++ b/packages/nodes/src/turbine-vent/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { turbineVentDefinition } from './definition' import TurbineVentPreview from './preview' @@ -80,6 +85,15 @@ const TurbineVentTool = () => { setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode({ + ...previewNode, + rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), + }), + }) event.stopPropagation() } @@ -104,6 +118,7 @@ const TurbineVentTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [vent.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -115,8 +130,9 @@ const TurbineVentTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -126,6 +142,7 @@ const TurbineVentTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( From 7ac7c3e67d45cb3f8611385c2a73a6a8746c8b39 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 22 Jun 2026 17:40:56 +0530 Subject: [PATCH 13/23] Improve duct and placement routing --- apps/ifc-converter/next-env.d.ts | 2 +- packages/core/src/registry/handles.ts | 39 ++ packages/core/src/registry/index.ts | 1 + packages/core/src/schema/nodes/dormer.ts | 2 +- .../components/editor/node-arrow-handles.tsx | 124 +++- packages/nodes/src/door/preview.tsx | 2 +- packages/nodes/src/dormer/definition.ts | 77 ++- packages/nodes/src/dormer/move-tool.tsx | 1 + .../nodes/src/dormer/placement-guides.tsx | 38 +- packages/nodes/src/duct-fitting/move-tool.tsx | 15 +- .../src/duct-fitting/parametrics.test.ts | 170 +++++ .../nodes/src/duct-fitting/parametrics.ts | 61 +- packages/nodes/src/duct-fitting/selection.tsx | 441 ++++++++++++- packages/nodes/src/duct-segment/definition.ts | 2 +- packages/nodes/src/duct-segment/move-tool.tsx | 90 ++- packages/nodes/src/duct-segment/selection.tsx | 562 ++++++++++++---- packages/nodes/src/duct-segment/tool.tsx | 64 +- packages/nodes/src/pipe-segment/tool.tsx | 5 +- .../nodes/src/shared/auto-offset-tag.test.ts | 155 +++++ packages/nodes/src/shared/auto-offset-tag.ts | 139 ++++ packages/nodes/src/shared/mep-ghost.tsx | 30 +- .../roof-surface-placement-guides.test.ts | 354 +++++++++++ .../shared/roof-surface-placement-guides.ts | 252 +++++++- .../src/shared/run-translation-offset.test.ts | 152 +++++ .../src/shared/run-translation-offset.ts | 190 ++++++ .../nodes/src/shared/selection-handles.tsx | 32 +- .../nodes/src/shared/vertical-offset.test.ts | 600 ++++++++++++++++++ packages/nodes/src/shared/vertical-offset.ts | 453 ++++++++++++- packages/nodes/src/window/definition.ts | 3 + packages/nodes/src/window/preview.tsx | 11 +- 30 files changed, 3780 insertions(+), 287 deletions(-) create mode 100644 packages/nodes/src/duct-fitting/parametrics.test.ts create mode 100644 packages/nodes/src/shared/auto-offset-tag.test.ts create mode 100644 packages/nodes/src/shared/auto-offset-tag.ts create mode 100644 packages/nodes/src/shared/roof-surface-placement-guides.test.ts create mode 100644 packages/nodes/src/shared/run-translation-offset.test.ts create mode 100644 packages/nodes/src/shared/run-translation-offset.ts create mode 100644 packages/nodes/src/shared/vertical-offset.test.ts diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 5824255c9..a248b54d9 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -182,6 +182,25 @@ export type LinearResizeHandle = { * the roof shell below it. Only consulted when `shape === 'tracker'`. */ trackerBaseY?: (node: N, sceneApi: SceneApi) => number + /** + * Stand the chevron blade up into the node's facing plane instead of + * leaving it flat in the local XZ plane. For an `axis: 'x'` handle on a + * wall-mounted opening (door / window), the local XZ plane is horizontal, + * so the default blade is seen edge-on from the front — rotating it 90° + * about its pointing axis lays it in the wall face (local XY) so it reads + * face-on toward the camera. Chevron shape only; `axis: 'y'` handles are + * already stood up unconditionally so this is a no-op for them. + */ + faceNormal?: boolean + /** + * Gate this arrow behind a click-to-latch cube. When set, the arrow is + * hidden until the user clicks the {@link LatchHandle} cube declaring the + * same `group` name; clicking the cube again hides it. Lets a node keep a + * dense cluster (e.g. a dormer's window width/height arrows) collapsed + * behind a single grip until the user opts in. The latch state is local to + * the selection and resets when the node is deselected. + */ + latchGroup?: string } /** @@ -365,6 +384,25 @@ export type TranslateHandle = { portal?: HandlePortal } +/** + * Click-to-latch cube. Renders a small persistent cube at `placement` that + * toggles the visibility of every handle tagged with the matching + * {@link LinearResizeHandle.latchGroup} `group`. Clicking the cube once shows + * the group's arrows; clicking again hides them. The latch state is local to + * the current selection and resets on deselect. + * + * Mirrors the duct-fitting selection cube but driven by descriptor data so any + * node can collapse a dense arrow cluster behind one grip — e.g. a dormer's + * window width/height arrows latch behind a cube at the window center. + */ +export type LatchHandle = { + kind: 'latch' + /** The `latchGroup` name whose arrows this cube reveals / hides. */ + group: string + placement: HandlePlacement + portal?: HandlePortal +} + export type HandleDescriptor = | LinearResizeHandle | RadialResizeHandle @@ -372,6 +410,7 @@ export type HandleDescriptor = | EndpointMoveHandle | TapActionHandle | TranslateHandle + | LatchHandle /** * Static array, or a function for shape-dependent cases (column diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index e8055706b..e4a936e43 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -9,6 +9,7 @@ export type { HandleList, HandlePlacement, HandlePortal, + LatchHandle, LinearResizeHandle, RadialResizeHandle, TapActionHandle, diff --git a/packages/core/src/schema/nodes/dormer.ts b/packages/core/src/schema/nodes/dormer.ts index d4b772c0c..a5702caa7 100644 --- a/packages/core/src/schema/nodes/dormer.ts +++ b/packages/core/src/schema/nodes/dormer.ts @@ -91,7 +91,7 @@ export const DormerNode = BaseNode.extend({ windowCornerRadii: z .tuple([z.number(), z.number(), z.number(), z.number()]) .default(DEFAULT_CORNER_RADII), - windowSill: z.boolean().default(true), + windowSill: z.boolean().default(false), windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH), windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS), }).describe( diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index c9435c55a..d5ade1c8d 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -9,6 +9,7 @@ import { DEFAULT_ANGLE_STEP, type HandleDescriptor, type HandlePortal, + type LatchHandle, type LinearResizeHandle, nodeRegistry, type RadialResizeHandle, @@ -374,6 +375,21 @@ function NodeArrowHandlesForNode({ // hook count between renders and trip React's rules-of-hooks check. const [activeIndex, setActiveIndex] = useState(null) const [preDragNode, setPreDragNode] = useState(null) + // Latch groups currently toggled open. A `latch` cube descriptor flips its + // group here on click; arrows tagged with a `latchGroup` only render while + // their group is in this set. Local to this mount, so it resets on deselect + // (the rig remounts per selection — see the `key` on NodeArrowHandlesForNode). + const [openLatchGroups, setOpenLatchGroups] = useState>(() => new Set()) + const toggleLatchGroup = useMemo( + () => (group: string) => + setOpenLatchGroups((prev) => { + const next = new Set(prev) + if (next.has(group)) next.delete(group) + else next.add(group) + return next + }), + [], + ) const dragControls = useMemo( () => ({ onStart: (index: number, snapshot: AnyNode) => { @@ -404,21 +420,36 @@ function NodeArrowHandlesForNode({ // here, or they'd lag behind the moving item. const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate' - const arrows = descriptors.map((descriptor, index) => ( - - )) + const arrows = descriptors.map((descriptor, index) => { + // A `latch` cube toggles its group's visibility; render it always. + if (descriptor.kind === 'latch') { + return ( + + ) + } + // Arrows tagged with a latch group stay hidden until that group is open. + const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined + if (latchGroup && !openLatchGroups.has(latchGroup)) return null + return ( + + ) + }) return createPortal( @@ -713,10 +744,19 @@ function LinearArrow({ // X+Z rotation chain matching DoorHeightArrowHandle. When the handle // sits below the node (placement Y < 0, e.g. window bottom arrow), // flip the Z rotation so the chevron points outward (downward). + // + // For axis === 'x' with `faceNormal` (wall-mounted opening width arrows), + // roll the blade 90° about its own pointing (X) axis so it stands up from + // the horizontal XZ plane into the node's facing plane (XY = the wall + // face) — otherwise the blade is seen edge-on from the front. + const faceNormalX = + descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true const innerRotation: [number, number, number] = descriptor.axis === 'y' ? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2] - : [0, 0, 0] + : faceNormalX + ? [Math.PI / 2, 0, 0] + : [0, 0, 0] // Optional guide decoration — linear handles use it for curved-stair // width / inner-radius rings; radial handles use it for the column's @@ -802,6 +842,7 @@ function LinearArrow({ onPointerDown={activate} placement={{ position, rotation: [0, rotationY, 0], baseScale }} shape="chevron" + thin > {showLabel ? : null} @@ -1197,6 +1238,7 @@ function ArcArrow({ baseScale, }} shape={isRotateShape ? 'curved-arrow' : 'chevron'} + thin /> ) @@ -1319,6 +1361,56 @@ function TapActionArrow({ onPointerDown={onActivate} placement={{ position, rotation, baseScale }} shape={shape === 'move-cross' ? 'move-cross' : 'chevron'} + thin + /> + ) +} + +// Click-to-latch cube. A persistent grip (the `tracker` cube) that toggles +// the visibility of every arrow tagged with its `latchGroup` on click. Sized +// to match the duct selection cube (`baseScale = zoom`, full TRACKER_CUBE_SIZE) +// so every latch grip reads the same across the app. Stays highlighted while +// its group is open so the user can tell it's engaged. +function LatchCube({ + descriptor, + node, + open, + onToggle, +}: { + descriptor: LatchHandle + node: AnyNode + open: boolean + onToggle: (group: string) => void +}) { + const [isHovered, setIsHovered] = useState(false) + const { camera } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom + + const placementSceneApi = useMemo(() => createSceneApi(useScene), []) + const position = descriptor.placement.position(node, placementSceneApi) + const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 + + // Route through the shared tap path so the cube click is swallowed before it + // reaches the select tool — stops R3F propagation, suppresses box-select, and + // eats the trailing DOM click that would otherwise select the host node. + const onPointerDown = useHandleDrag({ + kind: 'tap', + onTap: () => { + setIsHovered(false) + onToggle(descriptor.group) + }, + }) + + return ( + ) } diff --git a/packages/nodes/src/door/preview.tsx b/packages/nodes/src/door/preview.tsx index 8cd9b6375..f2441cafb 100644 --- a/packages/nodes/src/door/preview.tsx +++ b/packages/nodes/src/door/preview.tsx @@ -29,7 +29,7 @@ const DoorPreview = ({ const m = buildDoorPreviewMesh(node) m.layers.set(EDITOR_LAYER) return m - }, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount]) + }, [node]) // Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips; // its cleanup only disposes the clones it made. diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index ce929a785..587c7feb7 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -33,6 +33,9 @@ const MAX_SKIRT = 6 const WINDOW_SIDE_HANDLE_OFFSET = 0.15 const WINDOW_HEIGHT_HANDLE_OFFSET = 0.15 const WINDOW_FACE_Z_OFFSET = 0.05 +// The four window-edge arrows latch behind a cube at the window center; +// they stay hidden until the user clicks that cube to open the group. +const WINDOW_LATCH_GROUP = 'dormer-window' // Lower clamp for window dims matches the geometry's internal clamp // in `getDormerSkirtWindowDims` (0.1m). Upper clamps depend on the // dormer dimensions and are resolved per-handle via the function form @@ -109,21 +112,43 @@ function dormerWidthHandle(side: 'left' | 'right'): HandleDescriptor { +// Depth arrow on the +Z (front) or -Z (back) side. Asymmetric resize: +// dragging one arrow grows the dormer outward from its own edge while +// the opposite edge stays world-fixed in segment frame — same pattern +// as `dormerWidthHandle`, just on the Z axis. `apply` recomputes +// `position` so the anchored edge stays at the same segment-local point +// even when the dormer is Y-rotated: project the dormer's local +Z onto +// segment frame via (sin r, cos r), find the anchored edge's segment- +// local XZ from the pre-drag node, then place the new center half a new- +// depth away from that anchor in the same direction. +function dormerDepthHandle(side: 'front' | 'back'): HandleDescriptor { + const sign = side === 'front' ? 1 : -1 return { kind: 'linear-resize', axis: 'z', - anchor: 'center', + // 'min' = -Z edge anchored (front arrow grows the +Z edge outward). + // 'max' = +Z edge anchored (back arrow grows the -Z edge outward). + anchor: side === 'front' ? 'min' : 'max', min: MIN_DIM, currentValue: (n) => n.depth, - apply: (_n, newValue) => ({ depth: newValue }), + apply: (initial, newDepth) => { + const rotY = initial.rotation ?? 0 + const armX = Math.sin(rotY) + const armZ = Math.cos(rotY) + const anchorX = initial.position[0] - sign * (initial.depth / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.depth / 2) * armZ + const newCenterX = anchorX + sign * (newDepth / 2) * armX + const newCenterZ = anchorZ + sign * (newDepth / 2) * armZ + return { + depth: newDepth, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, placement: { - position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET], + position: (n) => [0, getBodyMidY(n), sign * (n.depth / 2 + SIDE_HANDLE_OFFSET)], + // The renderer auto-yaws axis-'z' chevrons by -π/2 so the default + // points +Z (front). Flip the back chevron 180° to point -Z. + rotationY: () => (side === 'front' ? 0 : Math.PI), }, } } @@ -273,6 +298,11 @@ function dormerWindowWidthHandle(side: 'left' | 'right'): HandleDescriptor