nodes: add MEP movement controls and DWV parity (#438)
* 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 <noreply@anthropic.com> * fixed conflict * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat(mep): click-to-latch cube handles for duct + fitting editing 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. * fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag Build a fully-determined basis for the duct roll gizmo so the curved arrow always seats at the top-outer 45° corner regardless of run direction, instead of an arbitrary apex from a single setFromUnitVectors. The selection-rig ±Y arrows now own vertical movement, so the redundant Ctrl-modifier riser drag is removed from the fitting move tool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): run-aligned duct handles, swing snapping, elbow flatten Align the duct run-center cube + horizontal arrows to the run axis (matching the per-vertex handles) while keeping whole-run translate. Endpoint side / up-down swing arrows now follow grid snap points and port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow realign + fitting schemas to flatten to a straight 0° coupling. Surface HVAC-specific hints in the select-mode helper panel for duct / fitting. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): per-segment linesets/liquid-lines with joint-follow editing Linesets and liquid lines now commit one independent two-point node per drawn segment instead of folding into a single mitered polyline, so each line selects and deletes on its own. Endpoint caps fill shared-coordinate joints so connected segments still read as continuous pipe. Dragging a shared endpoint carries mated segments along via port connectivity (Alt detaches), so a run still edits as one welded piece. Liquid-line follow mode traces the whole connected lineset run, laying a per-segment parallel line down its full assembled length. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag 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 <noreply@anthropic.com> * 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. * Improve duct and placement routing * Fix duct vertical movement routing * Fix duct vertical offsets and roof accessory movement * Add DWV movement parity and line endpoint controls * Fix MEP handle review issues * Fix chimney placement and duct offset cleanup * Use snapped targets for roof accessory commits * fix(nodes): repair MEP movement review issues - auto-fitting: tee branch now follows the drawn lateral angle; update the stale square-tee test + doc comment that contradicted the rewrite - duct-segment: re-enable the vertical auto-offset rewind (the disabled stub left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers and stacked duplicates); remove the dead stub - duct-segment: strip the stale auto-offset tag on manual corner/roll commits so the horizontal-move path no longer trusts an out-of-date base - chimney: resume history before mutating segment children arrays so a cross-segment move reparents in one tracked transaction (undo stays consistent) - dormer: align schema test with the new windowSill=false default * fix: address mep movement review issues * fix: address follow-up mep review comments * fix: address additional mep review comments --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: pascal-open-bot <open@pascal.app>
This commit is contained in:
co-authored by
Claude Opus 4.6
pascal-open-bot
parent
ae90da6554
commit
a71de82ccb
@@ -5,6 +5,7 @@ import {
|
||||
type BoxVentNode,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
@@ -21,8 +22,14 @@ import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
snapRelativeRoofDragTarget,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfaceNodePlacementGuides,
|
||||
snapRoofSurfaceNodeTarget,
|
||||
} from '../shared/roof-surface-placement-guides'
|
||||
import BoxVentPreview from './preview'
|
||||
|
||||
/**
|
||||
@@ -72,10 +79,21 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
lastSnap = null
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
|
||||
const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => {
|
||||
const rawTarget = roofDrag.resolve(event)
|
||||
if (!rawTarget) return null
|
||||
return snapRoofSurfaceNodeTarget({
|
||||
target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true),
|
||||
node,
|
||||
bypass: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
const target = resolveSnappedTarget(event)
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
@@ -102,12 +120,18 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
publishRoofSurfaceNodePlacementGuides({
|
||||
roof: event.node as RoofNode,
|
||||
segment: target.segment,
|
||||
center: [target.localX, target.localY, target.localZ],
|
||||
node,
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
@@ -152,6 +176,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
if (obj) obj.visible = true
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -172,6 +197,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
@@ -191,6 +217,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
@@ -223,6 +250,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
// the original mesh visible rather than stranded invisible.
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
clearRoofSurfacePlacementGuides()
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -10,11 +10,25 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { consumePlacementDragRelease, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { createRelativeRoofDrag, type RelativeRoofDragTarget } from '../shared/relative-roof-drag'
|
||||
import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
snapRelativeRoofDragTarget,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfaceNodePlacementGuides,
|
||||
snapRoofSurfaceNodeTarget,
|
||||
} from '../shared/roof-surface-placement-guides'
|
||||
import ChimneyPreview from './preview'
|
||||
|
||||
const tmpMatrix = new THREE.Matrix4()
|
||||
@@ -67,6 +81,25 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeBuildingId) return
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const original = {
|
||||
position: [...node.position] as [number, number, number],
|
||||
rotation: node.rotation ?? 0,
|
||||
roofSegmentId: node.roofSegmentId,
|
||||
parentId: node.parentId,
|
||||
metadata: node.metadata,
|
||||
}
|
||||
const meta =
|
||||
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
if (node.id) {
|
||||
const chimneyObj = sceneRegistry.nodes.get(node.id)
|
||||
if (chimneyObj) chimneyObj.visible = false
|
||||
}
|
||||
|
||||
const computeSegmentXform = (segmentId: string): SegmentTransform | null => {
|
||||
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||
@@ -84,25 +117,33 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
}
|
||||
|
||||
let lastTarget: RelativeRoofDragTarget | null = null
|
||||
let committed = false
|
||||
const roofDrag = createRelativeRoofDrag({
|
||||
position: [...node.position] as [number, number, number],
|
||||
roofSegmentId: node.roofSegmentId,
|
||||
position: original.position,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
})
|
||||
|
||||
const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => {
|
||||
const rawTarget = roofDrag.resolve(event)
|
||||
if (!rawTarget) return null
|
||||
return snapRoofSurfaceNodeTarget({
|
||||
target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true),
|
||||
node,
|
||||
bypass: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
}
|
||||
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
setSegmentXform(null)
|
||||
setHitLocal(null)
|
||||
setPreviewSegment(null)
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
}
|
||||
lastTarget = target
|
||||
const target = resolveSnappedTarget(event)
|
||||
if (!target) return clearTarget()
|
||||
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
const sz = Math.round(target.localZ * 20) / 20
|
||||
@@ -113,26 +154,32 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
}
|
||||
|
||||
const xform = computeSegmentXform(target.segment.id)
|
||||
if (!xform) return
|
||||
if (!xform) return clearTarget()
|
||||
lastTarget = target
|
||||
setSegmentXform(xform)
|
||||
setHitLocal([target.localX, target.localY, target.localZ])
|
||||
setPreviewSegment(target.segment)
|
||||
publishRoofSurfaceNodePlacementGuides({
|
||||
roof: event.node,
|
||||
segment: target.segment,
|
||||
center: [target.localX, target.localY, target.localZ],
|
||||
node,
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onClick = (event: RoofEvent) => {
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
if (committed) return
|
||||
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const state = useScene.getState()
|
||||
|
||||
// Strip the `isNew` flag — only used to mark a duplicate clone
|
||||
// that hasn't been committed yet.
|
||||
const meta =
|
||||
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const { isNew, ...restMeta } = meta as { isNew?: boolean }
|
||||
const cleanedMeta = Object.keys(restMeta).length > 0 ? restMeta : undefined
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
|
||||
// Duplicate (clone with no committed id yet) → create a fresh
|
||||
// chimney parented to the hit segment. Plain move (existing id,
|
||||
@@ -143,29 +190,105 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
...node,
|
||||
id: undefined as never,
|
||||
roofSegmentId: target.segment.id,
|
||||
parentId: target.segment.id,
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
visible: true,
|
||||
metadata: cleanedMeta,
|
||||
})
|
||||
state.createNode(committed, target.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(target.segment.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
state.applyNodeChanges({
|
||||
delete: node.id ? [node.id as AnyNodeId] : [],
|
||||
create: [{ node: committed, parentId: targetSegmentId }],
|
||||
})
|
||||
state.dirtyNodes.add(targetSegmentId)
|
||||
setSelection({ selectedIds: [committed.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
} else {
|
||||
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
|
||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
const reparenting = Boolean(prevSegmentId && prevSegmentId !== targetSegmentId)
|
||||
// Resume BEFORE any scene edits so the reparent (both segments'
|
||||
// children arrays + the chimney's own host/position update) lands as
|
||||
// one tracked transaction. Otherwise undo reverts the chimney but
|
||||
// leaves the children arrays inconsistent with its parentId.
|
||||
useScene.temporal.getState().resume()
|
||||
if (reparenting) {
|
||||
const oldSeg = state.nodes[prevSegmentId!] as RoofSegmentNode | undefined
|
||||
if (oldSeg) {
|
||||
state.updateNode(prevSegmentId!, {
|
||||
children: (oldSeg.children ?? []).filter((id) => id !== node.id),
|
||||
})
|
||||
}
|
||||
const newSeg = state.nodes[targetSegmentId] as RoofSegmentNode | undefined
|
||||
if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
|
||||
state.updateNode(targetSegmentId, {
|
||||
children: [...(newSeg.children ?? []), node.id],
|
||||
})
|
||||
}
|
||||
state.dirtyNodes.add(prevSegmentId!)
|
||||
}
|
||||
state.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: target.segment.id,
|
||||
parentId: target.segment.id,
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
rotation: original.rotation,
|
||||
visible: true,
|
||||
metadata: cleanedMeta,
|
||||
})
|
||||
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
|
||||
state.dirtyNodes.add(target.segment.id as AnyNodeId)
|
||||
useScene.temporal.getState().pause()
|
||||
state.dirtyNodes.add(targetSegmentId)
|
||||
state.dirtyNodes.add(node.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [node.id] })
|
||||
}
|
||||
const obj = node.id && !isNew ? sceneRegistry.nodes.get(node.id) : null
|
||||
if (obj) obj.visible = true
|
||||
clearRoofSurfacePlacementGuides()
|
||||
setMovingNode(null)
|
||||
triggerSFX('sfx:item-place')
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (isNew) {
|
||||
if (node.id) {
|
||||
const parentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
if (parentId) {
|
||||
const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined
|
||||
if (parent) {
|
||||
useScene.getState().updateNode(parentId, {
|
||||
children: (parent.children ?? []).filter((id) => id !== node.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
setMovingNode(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (node.id) {
|
||||
useScene.getState().updateNode(node.id as AnyNodeId, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
roofSegmentId: original.roofSegmentId as AnyNodeId | undefined,
|
||||
parentId: original.parentId as AnyNodeId | undefined,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.roofSegmentId) {
|
||||
useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId)
|
||||
}
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
}
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
setMovingNode(null)
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!lastTarget) return
|
||||
@@ -179,6 +302,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onClick)
|
||||
emitter.on('roof:leave', clearTarget)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
@@ -186,7 +310,15 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onClick)
|
||||
emitter.off('roof:leave', clearTarget)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
if (node.id) {
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
}
|
||||
clearRoofSurfacePlacementGuides()
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [activeBuildingId, node, setMovingNode, setSelection])
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -2404,18 +2404,7 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
|
||||
textures,
|
||||
colorPreset,
|
||||
}),
|
||||
[
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
node.materialPreset,
|
||||
node.slots,
|
||||
sceneMaterials,
|
||||
],
|
||||
[shading, textures, colorPreset, node, sceneMaterials],
|
||||
)
|
||||
|
||||
useRegistry(node.id, node.type, ref)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type CupolaNode,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
@@ -21,8 +22,14 @@ import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
snapRelativeRoofDragTarget,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfaceNodePlacementGuides,
|
||||
snapRoofSurfaceNodeTarget,
|
||||
} from '../shared/roof-surface-placement-guides'
|
||||
import CupolaPreview from './preview'
|
||||
|
||||
/**
|
||||
@@ -70,10 +77,21 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
lastSnap = null
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
|
||||
const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => {
|
||||
const rawTarget = roofDrag.resolve(event)
|
||||
if (!rawTarget) return null
|
||||
return snapRoofSurfaceNodeTarget({
|
||||
target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true),
|
||||
node,
|
||||
bypass: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
const target = resolveSnappedTarget(event)
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
@@ -100,12 +118,18 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
publishRoofSurfaceNodePlacementGuides({
|
||||
roof: event.node as RoofNode,
|
||||
segment: target.segment,
|
||||
center: [target.localX, target.localY, target.localZ],
|
||||
node,
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
@@ -146,6 +170,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
if (obj) obj.visible = true
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -164,6 +189,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
@@ -183,6 +209,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
@@ -212,6 +239,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
clearRoofSurfacePlacementGuides()
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('DormerNode schema', () => {
|
||||
expect(parsed.height).toBe(0)
|
||||
expect(parsed.roofType).toBe('gable')
|
||||
expect(parsed.windowShape).toBe('rectangle')
|
||||
expect(parsed.windowSill).toBe(true)
|
||||
expect(parsed.windowSill).toBe(false)
|
||||
})
|
||||
|
||||
test('windowColumns / windowRows clamped to [1, 8]', () => {
|
||||
|
||||
@@ -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<DormerNodeT
|
||||
}
|
||||
}
|
||||
|
||||
// Depth arrow on the +Z side. Symmetric (anchor 'center') to match
|
||||
// chimney's known-working handle count — splitting depth into asymmetric
|
||||
// front + back chevrons puts the dormer over the per-node MRT/TSL
|
||||
// budget that chimney already documents (see `chimneyHandles` factory).
|
||||
// Re-evaluate the split once that pipeline issue is pinned down.
|
||||
function dormerDepthHandle(): HandleDescriptor<DormerNodeType> {
|
||||
// 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<DormerNodeType> {
|
||||
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<Dorme
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
// Stand the blade up into the gable face so it reads flat-on like the
|
||||
// top/bottom window-height arrows instead of edge-on.
|
||||
faceNormal: true,
|
||||
// Hidden until the user clicks the window-center latch cube.
|
||||
latchGroup: WINDOW_LATCH_GROUP,
|
||||
anchor: side === 'right' ? 'min' : 'max',
|
||||
min: MIN_WINDOW_DIM,
|
||||
// Cap at the dormer's window field — keep a 0.1m gap on each side
|
||||
@@ -317,6 +347,8 @@ function dormerWindowHeightHandle(side: 'top' | 'bottom'): HandleDescriptor<Dorm
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
// Hidden until the user clicks the window-center latch cube.
|
||||
latchGroup: WINDOW_LATCH_GROUP,
|
||||
// 'min' = bottom edge anchored (top arrow grows the top edge up).
|
||||
// 'max' = top edge anchored (bottom arrow drops the bottom edge).
|
||||
anchor: side === 'top' ? 'min' : 'max',
|
||||
@@ -350,12 +382,37 @@ function dormerWindowHeightHandle(side: 'top' | 'bottom'): HandleDescriptor<Dorm
|
||||
}
|
||||
}
|
||||
|
||||
// Window-center latch cube. Sits at the window center on the exposed
|
||||
// gable face; clicking it reveals / hides the four window edge arrows
|
||||
// (width L/R + height top/bottom) tagged with `WINDOW_LATCH_GROUP`.
|
||||
// Mirrors the duct-fitting selection cube but driven by the shared
|
||||
// latch descriptor so the dense window cluster stays collapsed behind
|
||||
// one grip until the user opts in.
|
||||
function dormerWindowLatchHandle(): HandleDescriptor<DormerNodeType> {
|
||||
return {
|
||||
kind: 'latch',
|
||||
group: WINDOW_LATCH_GROUP,
|
||||
placement: {
|
||||
position: (n, sceneApi) => {
|
||||
const faceSign = getExposedFaceZSign(n, sceneApi)
|
||||
return [
|
||||
n.windowOffsetX,
|
||||
getWindowCenterY(n),
|
||||
faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET),
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const dormerHandles: HandleDescriptor<DormerNodeType>[] = [
|
||||
dormerWidthHandle('right'),
|
||||
dormerWidthHandle('left'),
|
||||
dormerDepthHandle(),
|
||||
dormerDepthHandle('front'),
|
||||
dormerDepthHandle('back'),
|
||||
dormerWallHeightHandle(),
|
||||
dormerRotateHandle(),
|
||||
dormerWindowLatchHandle(),
|
||||
dormerWindowWidthHandle('right'),
|
||||
dormerWindowWidthHandle('left'),
|
||||
dormerWindowHeightHandle('top'),
|
||||
|
||||
@@ -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,95 @@ 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<string, unknown>)
|
||||
: {}
|
||||
const {
|
||||
isNew: _isNew,
|
||||
isTransient: _isTransient,
|
||||
...rest
|
||||
} = m as {
|
||||
isNew?: boolean
|
||||
isTransient?: boolean
|
||||
}
|
||||
return Object.keys(rest).length > 0 ? rest : undefined
|
||||
})()
|
||||
// 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<string, unknown>)
|
||||
: {}
|
||||
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),
|
||||
})
|
||||
}
|
||||
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],
|
||||
})
|
||||
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 (
|
||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||
{hitSegment && (
|
||||
<DormerPlacementGuides
|
||||
center={hitLocal}
|
||||
depth={previewNode.depth}
|
||||
movingId={node.id}
|
||||
rotation={ghostRotation}
|
||||
segment={hitSegment}
|
||||
width={previewNode.width}
|
||||
/>
|
||||
)}
|
||||
<group position={hitLocal}>
|
||||
<group rotation-y={ghostRotation}>
|
||||
<DormerPreview node={previewNode} />
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
'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'
|
||||
import {
|
||||
roofFaceKey,
|
||||
roofGuideBounds,
|
||||
roofSiblingSpacing,
|
||||
} from '../shared/roof-surface-placement-guides'
|
||||
|
||||
// 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 ALIGN_COLOR = 0xef_44_44
|
||||
const PILL_BG = '#6366f1'
|
||||
const BADGE_BG = '#ec4899'
|
||||
// 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,
|
||||
})
|
||||
const alignMaterial = new LineBasicNodeMaterial({
|
||||
color: ALIGN_COLOR,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
})
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
type DormerGuide =
|
||||
| {
|
||||
id: string
|
||||
from: Vec3
|
||||
to: Vec3
|
||||
kind: 'align-line' | 'dimension'
|
||||
value?: number
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
at: Vec3
|
||||
kind: 'badge'
|
||||
value: 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 `<DormerPreview>` 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,
|
||||
movingId,
|
||||
}: {
|
||||
segment: RoofSegmentNode
|
||||
center: Vec3
|
||||
width: number
|
||||
depth: number
|
||||
rotation: number
|
||||
movingId?: string
|
||||
}) {
|
||||
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 movingBounds = roofGuideBounds(center, { width, depth, rotation })
|
||||
|
||||
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: DormerGuide[] = []
|
||||
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,
|
||||
kind: 'dimension',
|
||||
value,
|
||||
})
|
||||
}
|
||||
const siblingSpacing = roofSiblingSpacing<DormerGuide>({
|
||||
segment,
|
||||
movingId,
|
||||
movingBounds,
|
||||
faceKey: roofFaceKey(faceBounds.polygon),
|
||||
dimension: (id, [ax, az], [bx, bz]) => {
|
||||
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 null
|
||||
return { id, from, to, kind: 'dimension', value }
|
||||
},
|
||||
alignLine: (id, [ax, az], [bx, bz]) => {
|
||||
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 null
|
||||
return { id, from, to, kind: 'align-line' }
|
||||
},
|
||||
badge: (id, [x, z], value) => {
|
||||
if (value < MIN_GAP_M) return null
|
||||
return {
|
||||
id,
|
||||
at: [x, surfaceY(x, z), z],
|
||||
kind: 'badge',
|
||||
value,
|
||||
}
|
||||
},
|
||||
measure: ([ax, az], [bx, bz]) => {
|
||||
const ay = surfaceY(ax, az)
|
||||
const by = surfaceY(bx, bz)
|
||||
return Math.hypot(bx - ax, by - ay, bz - az)
|
||||
},
|
||||
})
|
||||
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 (!siblingSpacing.blockedSides.left && itemMinX > faceMinX + MIN_GAP_M) {
|
||||
push('left', faceMinX, cz, itemMinX, cz)
|
||||
}
|
||||
if (!siblingSpacing.blockedSides.right && 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 (!siblingSpacing.blockedSides.bottom && itemMinZ > faceMinZ + MIN_GAP_M) {
|
||||
push('back', cx, faceMinZ, cx, itemMinZ)
|
||||
}
|
||||
if (!siblingSpacing.blockedSides.top && itemMaxZ < faceMaxZ - MIN_GAP_M) {
|
||||
push('front', cx, itemMaxZ, cx, faceMaxZ)
|
||||
}
|
||||
}
|
||||
guides.push(...siblingSpacing.guides)
|
||||
|
||||
return (
|
||||
<>
|
||||
{guides.map((g) => (
|
||||
<Guide key={g.id} guide={g} unit={unit} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Guide({ guide, unit }: { guide: DormerGuide; unit: 'metric' | 'imperial' }) {
|
||||
if (guide.kind === 'badge') {
|
||||
return <GuideBadge at={guide.at} pill={`= ${formatMeasurement(guide.value, unit)}`} />
|
||||
}
|
||||
|
||||
return (
|
||||
<GuideLine
|
||||
from={guide.from}
|
||||
kind={guide.kind}
|
||||
pill={guide.value === undefined ? undefined : formatMeasurement(guide.value, unit)}
|
||||
to={guide.to}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GuideBadge({ at, pill }: { at: Vec3; pill: string }) {
|
||||
return (
|
||||
<Html
|
||||
center
|
||||
position={at}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[20, 0]}
|
||||
>
|
||||
<div
|
||||
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-semibold font-sans text-[11px] text-white"
|
||||
style={{ backgroundColor: BADGE_BG }}
|
||||
>
|
||||
{pill}
|
||||
</div>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
function GuideLine({
|
||||
from,
|
||||
to,
|
||||
pill,
|
||||
kind,
|
||||
}: {
|
||||
from: Vec3
|
||||
to: Vec3
|
||||
pill?: string
|
||||
kind: DormerGuide['kind']
|
||||
}) {
|
||||
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, kind === 'align-line' ? alignMaterial : guideMaterial)
|
||||
line.frustumCulled = false
|
||||
line.layers.set(EDITOR_LAYER)
|
||||
line.renderOrder = 1000
|
||||
return { line, position }
|
||||
}, [kind])
|
||||
|
||||
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 (
|
||||
<>
|
||||
<primitive object={line} />
|
||||
{pill ? (
|
||||
<Html
|
||||
center
|
||||
position={mid}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[20, 0]}
|
||||
>
|
||||
<div
|
||||
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
|
||||
style={{ backgroundColor: PILL_BG }}
|
||||
>
|
||||
{pill}
|
||||
</div>
|
||||
</Html>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||
{hitSegment && (
|
||||
<DormerPlacementGuides
|
||||
center={hitLocal}
|
||||
depth={previewNode.depth}
|
||||
rotation={ghostRotation}
|
||||
segment={hitSegment}
|
||||
width={previewNode.width}
|
||||
/>
|
||||
)}
|
||||
<group position={hitLocal}>
|
||||
<group rotation-y={ghostRotation}>
|
||||
<DormerPreview node={previewNode} />
|
||||
|
||||
@@ -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<DormerSegmentTransform | null>(null)
|
||||
const [hitSegment, setHitSegment] = useState<RoofSegmentNode | null>(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,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { ductBodyPaint, ductBodySlots } from '../shared/duct-body-paint'
|
||||
import { rotateFittingNode } from '../shared/fitting-rotation'
|
||||
import { buildDuctFittingFloorplan } from './floorplan'
|
||||
import { buildDuctFittingGeometry } from './geometry'
|
||||
@@ -29,16 +30,16 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
fittingType: 'elbow',
|
||||
shape: 'round',
|
||||
shape: 'rect',
|
||||
width: 14,
|
||||
height: 8,
|
||||
shape2: 'round',
|
||||
shape2: 'rect',
|
||||
width2: 14,
|
||||
height2: 8,
|
||||
angle: 90,
|
||||
branchAngle: 90,
|
||||
diameter: 6,
|
||||
diameter2: 6,
|
||||
diameter: 12,
|
||||
diameter2: 12,
|
||||
ductMaterial: 'sheet-metal',
|
||||
system: 'supply',
|
||||
}),
|
||||
@@ -51,6 +52,8 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
||||
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
slots: () => ductBodySlots(),
|
||||
paint: ductBodyPaint,
|
||||
},
|
||||
|
||||
parametrics: ductFittingParametrics,
|
||||
@@ -75,6 +78,7 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
||||
n.diameter2,
|
||||
n.ductMaterial,
|
||||
n.system,
|
||||
n.slots,
|
||||
]),
|
||||
|
||||
ports: getDuctFittingPorts,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GeometryContext } from '@pascal-app/core'
|
||||
import type { ColorPreset, RenderShading } from '@pascal-app/viewer'
|
||||
import {
|
||||
BufferGeometry,
|
||||
CylinderGeometry,
|
||||
@@ -5,8 +7,8 @@ import {
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
Group,
|
||||
type Material,
|
||||
Mesh,
|
||||
type MeshStandardMaterial,
|
||||
SphereGeometry,
|
||||
TorusGeometry,
|
||||
Vector3,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
createDuctMaterial,
|
||||
INCHES_TO_METERS,
|
||||
} from '../duct-segment/geometry'
|
||||
import { DUCT_BODY_SLOT_ID } from '../shared/duct-body-paint'
|
||||
import { localFittingPorts } from './ports'
|
||||
import type { DuctFittingNode } from './schema'
|
||||
|
||||
@@ -76,7 +79,7 @@ function buildMiteredElbow(
|
||||
sweepM: number,
|
||||
cheekM: number,
|
||||
profileShape: 'rect' | 'oval',
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
): Mesh {
|
||||
const travelIn = inletPos.clone().multiplyScalar(-1).normalize() // inlet → junction
|
||||
const travelOut = outletPos.clone().normalize() // junction → outlet
|
||||
@@ -155,7 +158,7 @@ function buildRectToRoundLoft(
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
radius: number,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
): Mesh {
|
||||
const hw = widthM / 2
|
||||
const hh = heightM / 2
|
||||
@@ -212,9 +215,23 @@ function buildRectToRoundLoft(
|
||||
* height rides local +Y — for the horizontal-plane orientations trunks
|
||||
* are drawn in, that's world-vertical.
|
||||
*/
|
||||
export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
|
||||
export function buildDuctFittingGeometry(
|
||||
node: DuctFittingNode,
|
||||
ctx?: GeometryContext,
|
||||
shading: RenderShading = 'rendered',
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
sceneTheme?: string,
|
||||
): Group {
|
||||
const group = new Group()
|
||||
const material = createDuctMaterial(node)
|
||||
const material = createDuctMaterial(
|
||||
node,
|
||||
ctx?.materials,
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
)
|
||||
const radiusMain = (node.diameter * INCHES_TO_METERS) / 2
|
||||
const ports = localFittingPorts(node)
|
||||
const widthM = node.width * INCHES_TO_METERS
|
||||
@@ -459,5 +476,10 @@ export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
|
||||
group.add(collar)
|
||||
}
|
||||
|
||||
group.traverse((object) => {
|
||||
const mesh = object as Mesh
|
||||
if (mesh.isMesh) mesh.userData.slotId = DUCT_BODY_SLOT_ID
|
||||
})
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { ActionButton } from '@pascal-app/editor'
|
||||
import { ArrowLeftRight } from 'lucide-react'
|
||||
import type { DuctFittingNode } from './schema'
|
||||
|
||||
const WIDTH_MIN = 4
|
||||
const WIDTH_MAX = 60
|
||||
const HEIGHT_MIN = 3
|
||||
const HEIGHT_MAX = 40
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function DuctFittingSizeSwapEditor({
|
||||
node,
|
||||
onUpdate,
|
||||
}: {
|
||||
node: DuctFittingNode
|
||||
onUpdate: (patch: Partial<DuctFittingNode>) => void
|
||||
}) {
|
||||
const nextWidth = clamp(node.height, WIDTH_MIN, WIDTH_MAX)
|
||||
const nextHeight = clamp(node.width, HEIGHT_MIN, HEIGHT_MAX)
|
||||
|
||||
return (
|
||||
<div className="px-2">
|
||||
<ActionButton
|
||||
className="h-8 w-full flex-none"
|
||||
icon={<ArrowLeftRight className="h-3.5 w-3.5" />}
|
||||
label="Swap W/H"
|
||||
onClick={() => onUpdate({ width: nextWidth, height: nextHeight })}
|
||||
title="Swap width and height"
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
DragBoundingBox,
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
@@ -22,11 +23,13 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
|
||||
import { autoOffsetInvalidationUpdates } from '../shared/auto-offset-tag'
|
||||
import {
|
||||
type Aabb2D,
|
||||
collectGhostAlignmentCandidates,
|
||||
resolveGhostAlignment,
|
||||
} from '../shared/ghost-alignment'
|
||||
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
||||
import { buildDuctFittingGeometry } from './geometry'
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
@@ -174,11 +177,27 @@ 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
|
||||
|
||||
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
|
||||
const snap = bypass ? (v: number) => v : snapToGridStep
|
||||
|
||||
let x = snap(event.localPosition[0])
|
||||
let z = snap(event.localPosition[2])
|
||||
|
||||
@@ -198,17 +217,27 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
const next: Vec3 = [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) => {
|
||||
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
|
||||
}
|
||||
@@ -230,10 +259,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<AnyNode>)
|
||||
useScene.getState().markDirty(nodeId)
|
||||
// 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 }) ?? [])
|
||||
const scene = useScene.getState()
|
||||
scene.updateNodes([
|
||||
{ id: nodeId, data: { position: lastPos } as Partial<AnyNode> },
|
||||
...followUpdates,
|
||||
...autoOffsetInvalidationUpdates(scene.nodes, nodeId),
|
||||
])
|
||||
scene.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 +288,7 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -256,14 +300,39 @@ 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)
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { readAutoOffsetTag, withAutoOffsetTag } from '../shared/auto-offset-tag'
|
||||
import { getDuctFittingPorts } from './ports'
|
||||
|
||||
let ductFittingParametrics: typeof import('./parametrics')['ductFittingParametrics']
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
function equivalentDiameterIn(widthIn: number, heightIn: number): number {
|
||||
return 2 * Math.sqrt((widthIn * heightIn) / Math.PI)
|
||||
}
|
||||
|
||||
function rectElbow() {
|
||||
return DuctFittingNode.parse({
|
||||
id: 'duct-fitting_resize' as AnyNodeId,
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Resize elbow',
|
||||
fittingType: 'elbow',
|
||||
shape: 'rect',
|
||||
width: 14,
|
||||
height: 8,
|
||||
diameter: equivalentDiameterIn(14, 8),
|
||||
diameter2: equivalentDiameterIn(14, 8),
|
||||
ductMaterial: 'sheet-metal',
|
||||
system: 'supply',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
angle: 90,
|
||||
})
|
||||
}
|
||||
|
||||
function verticalRectRunFrom(point: Point, roll: number) {
|
||||
return DuctSegmentNode.parse({
|
||||
id: 'duct-segment_vertical' as AnyNodeId,
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Drawn vertical run',
|
||||
path: [point, [point[0], point[1] + 3, point[2]]],
|
||||
shape: 'rect',
|
||||
width: 14,
|
||||
height: 8,
|
||||
diameter: equivalentDiameterIn(14, 8),
|
||||
roll,
|
||||
ductMaterial: 'sheet-metal',
|
||||
insulationR: 0,
|
||||
system: 'supply',
|
||||
})
|
||||
}
|
||||
|
||||
describe('ductFittingParametrics', () => {
|
||||
beforeAll(async () => {
|
||||
mock.module('@pascal-app/editor', () => ({
|
||||
ActionButton: () => null,
|
||||
}))
|
||||
;({ ductFittingParametrics } = await import('./parametrics'))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
useScene.setState({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
readOnly: false,
|
||||
} as never)
|
||||
useScene.temporal.getState().clear()
|
||||
})
|
||||
|
||||
test('resizing a fitting retrims connected ducts without changing their roll', () => {
|
||||
const fitting = rectElbow()
|
||||
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const originalRoll = 0.37
|
||||
const duct = verticalRectRunFrom([...outlet.position] as Point, originalRoll)
|
||||
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[duct.id]: duct as AnyNode,
|
||||
},
|
||||
rootNodeIds: [fitting.id, duct.id],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
readOnly: false,
|
||||
} as never)
|
||||
|
||||
const patch = { width: 20 }
|
||||
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
||||
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
||||
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
||||
const ductUpdate = updates.find((u) => u.id === duct.id)
|
||||
|
||||
expect(ductUpdate).toBeDefined()
|
||||
expect((ductUpdate?.data as Partial<DuctSegmentNode>).path).toBeDefined()
|
||||
expect((ductUpdate?.data as Partial<DuctSegmentNode>).roll).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resizing a fitting refreshes a connected duct auto-offset base path', () => {
|
||||
const fitting = rectElbow()
|
||||
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||
const taggedDuct = DuctSegmentNode.parse({
|
||||
...duct,
|
||||
metadata: withAutoOffsetTag(duct.metadata, {
|
||||
group: 'aoff_resize',
|
||||
dy: 1,
|
||||
minted: ['duct-fitting_minted' as AnyNodeId],
|
||||
base: [{ id: duct.id, data: { path: duct.path } }],
|
||||
}),
|
||||
})
|
||||
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[taggedDuct.id]: taggedDuct as AnyNode,
|
||||
},
|
||||
rootNodeIds: [fitting.id, taggedDuct.id],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
readOnly: false,
|
||||
} as never)
|
||||
|
||||
const patch = { width: 20 }
|
||||
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
||||
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
||||
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
||||
const ductUpdate = updates.find((u) => u.id === taggedDuct.id)
|
||||
const nextOutlet = getDuctFittingPorts(next).find((p) => p.id === 'outlet')!
|
||||
const nextTag = readAutoOffsetTag({ metadata: ductUpdate?.data.metadata })
|
||||
const basePath = nextTag?.base.find((b) => b.id === taggedDuct.id)?.data.path as
|
||||
| Point[]
|
||||
| undefined
|
||||
|
||||
expect(basePath?.[0]).toEqual([...nextOutlet.position])
|
||||
})
|
||||
|
||||
test('deleting an elbow re-extends mated runs back onto the junction', () => {
|
||||
const fitting = rectElbow()
|
||||
const ports = getDuctFittingPorts(fitting)
|
||||
const outlet = ports.find((p) => p.id === 'outlet')!
|
||||
const inlet = ports.find((p) => p.id === 'inlet')!
|
||||
// Two runs meeting the elbow's collars — the L-shape the elbow trimmed.
|
||||
const outletRun = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||
const inletRun = DuctSegmentNode.parse({
|
||||
...verticalRectRunFrom([...inlet.position] as Point, 0),
|
||||
id: 'duct-segment_inlet' as AnyNodeId,
|
||||
path: [
|
||||
[...inlet.position] as Point,
|
||||
[inlet.position[0] - 3, inlet.position[1], inlet.position[2]],
|
||||
],
|
||||
})
|
||||
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[outletRun.id]: outletRun as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
}
|
||||
|
||||
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
||||
const inletUpdate = updates.find((u) => u.id === inletRun.id)
|
||||
|
||||
// Both mated endpoints snap back to the junction (the original corner).
|
||||
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
|
||||
...fitting.position,
|
||||
])
|
||||
expect((inletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([...fitting.position])
|
||||
})
|
||||
|
||||
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
|
||||
const fitting = rectElbow()
|
||||
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const nearOutlet: Point = [outlet.position[0], outlet.position[1], outlet.position[2] + 0.04]
|
||||
const outletRun = DuctSegmentNode.parse({
|
||||
...verticalRectRunFrom(nearOutlet, 0),
|
||||
id: 'duct-segment_outlet_gap' as AnyNodeId,
|
||||
})
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[outletRun.id]: outletRun as AnyNode,
|
||||
}
|
||||
|
||||
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
||||
|
||||
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
|
||||
...fitting.position,
|
||||
])
|
||||
})
|
||||
|
||||
test('deleting a generated elbow clears the owner duct auto-offset tag', () => {
|
||||
const fitting = rectElbow()
|
||||
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||
const taggedDuct = DuctSegmentNode.parse({
|
||||
...duct,
|
||||
metadata: withAutoOffsetTag(duct.metadata, {
|
||||
group: 'aoff_deleted_elbow',
|
||||
dy: 1,
|
||||
minted: [fitting.id],
|
||||
base: [{ id: duct.id, data: { path: duct.path } }],
|
||||
}),
|
||||
})
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[taggedDuct.id]: taggedDuct as AnyNode,
|
||||
}
|
||||
|
||||
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
|
||||
|
||||
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
|
||||
})
|
||||
|
||||
test('deleting a tee leaves mated runs untouched', () => {
|
||||
const tee = DuctFittingNode.parse({ ...rectElbow(), fittingType: 'tee' })
|
||||
const outlet = getDuctFittingPorts(tee).find((p) => p.id === 'outlet')!
|
||||
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[tee.id]: tee as AnyNode,
|
||||
[duct.id]: duct as AnyNode,
|
||||
}
|
||||
|
||||
expect(ductFittingParametrics.onDelete?.(tee, nodes) ?? []).toEqual([])
|
||||
})
|
||||
|
||||
test('resizing a generated fitting clears the owner duct auto-offset tag', () => {
|
||||
const fitting = rectElbow()
|
||||
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||
const taggedDuct = DuctSegmentNode.parse({
|
||||
...duct,
|
||||
metadata: withAutoOffsetTag(duct.metadata, {
|
||||
group: 'aoff_generated_fit',
|
||||
dy: 1,
|
||||
minted: [fitting.id],
|
||||
base: [{ id: duct.id, data: { path: duct.path } }],
|
||||
}),
|
||||
})
|
||||
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[taggedDuct.id]: taggedDuct as AnyNode,
|
||||
},
|
||||
rootNodeIds: [fitting.id, taggedDuct.id],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
readOnly: false,
|
||||
} as never)
|
||||
|
||||
const patch = { width: 20 }
|
||||
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
||||
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
||||
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
||||
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
|
||||
|
||||
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -7,19 +7,41 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { Vector3 } from 'three'
|
||||
import {
|
||||
ductPortDiameterIn,
|
||||
equivalentDiameterIn,
|
||||
ovalEquivalentDiameterIn,
|
||||
rollToContinueAcrossElbow,
|
||||
} from '../duct-segment/geometry'
|
||||
autoOffsetInvalidationUpdates,
|
||||
readAutoOffsetTag,
|
||||
withAutoOffsetTag,
|
||||
} from '../shared/auto-offset-tag'
|
||||
import { DuctFittingSizeSwapEditor } from './inspector-editors'
|
||||
import { getDuctFittingPorts } from './ports'
|
||||
import type { DuctFittingNode } from './schema'
|
||||
|
||||
/** Schema bounds for `diameter` / `diameter2`. */
|
||||
const clampDiameter = (d: number) => Math.min(48, Math.max(2, d))
|
||||
|
||||
const equivalentDiameterIn = (widthIn: number, heightIn: number): number =>
|
||||
2 * Math.sqrt((widthIn * heightIn) / Math.PI)
|
||||
|
||||
const ovalEquivalentDiameterIn = (widthIn: number, heightIn: number): number => {
|
||||
const minor = Math.min(widthIn, heightIn)
|
||||
const major = Math.max(widthIn, heightIn)
|
||||
const area = (major - minor) * minor + Math.PI * (minor / 2) ** 2
|
||||
return 2 * Math.sqrt(area / Math.PI)
|
||||
}
|
||||
|
||||
const ductPortDiameterIn = (node: DuctSegmentNode): number => {
|
||||
if (node.shape === 'rect' && node.width && node.height) {
|
||||
return equivalentDiameterIn(node.width, node.height)
|
||||
}
|
||||
if (node.shape === 'oval' && node.width && node.height) {
|
||||
return ovalEquivalentDiameterIn(node.width, node.height)
|
||||
}
|
||||
return node.diameter
|
||||
}
|
||||
|
||||
/** A duct endpoint sitting this close to a collar counts as mated. */
|
||||
const MATE_TOL_M = 0.03
|
||||
const MATE_TOL_M = 0.05
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
type DuctMate = { duct: DuctSegmentNode; endIndex: number }
|
||||
|
||||
@@ -28,10 +50,13 @@ type DuctMate = { duct: DuctSegmentNode; endIndex: number }
|
||||
* port id. Auto-minted joints place duct ends exactly on the collar, so
|
||||
* a tight distance check is enough — no connectivity graph yet.
|
||||
*/
|
||||
function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
|
||||
function matedDucts(
|
||||
fitting: DuctFittingNode,
|
||||
nodes: Record<AnyNodeId, AnyNode> = useScene.getState().nodes,
|
||||
): Map<string, DuctMate> {
|
||||
const mates = new Map<string, DuctMate>()
|
||||
const ports = getDuctFittingPorts(fitting)
|
||||
for (const node of Object.values(useScene.getState().nodes)) {
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'duct-segment') continue
|
||||
const duct = node as DuctSegmentNode
|
||||
for (const endIndex of [0, duct.path.length - 1]) {
|
||||
@@ -51,6 +76,25 @@ function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
|
||||
return mates
|
||||
}
|
||||
|
||||
function refreshedAutoOffsetMetadata(
|
||||
duct: DuctSegmentNode,
|
||||
endIndex: number,
|
||||
target: Point,
|
||||
): Record<string, unknown> | null {
|
||||
const tag = readAutoOffsetTag(duct)
|
||||
if (!tag) return null
|
||||
let changed = false
|
||||
const base = tag.base.map((patch) => {
|
||||
if (patch.id !== duct.id || !Array.isArray(patch.data.path)) return patch
|
||||
const path = patch.data.path.map((p) => (Array.isArray(p) ? [...p] : p))
|
||||
if (!Array.isArray(path[endIndex])) return patch
|
||||
path[endIndex] = [...target]
|
||||
changed = true
|
||||
return { ...patch, data: { ...patch.data, path } }
|
||||
})
|
||||
return changed ? withAutoOffsetTag(duct.metadata, { ...tag, base }) : null
|
||||
}
|
||||
|
||||
export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
||||
// Switching the run legs round↔rect flips the whole fitting and sizes
|
||||
// the new profile off the ducts actually mated to its collars, so the
|
||||
@@ -127,34 +171,48 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
||||
path[mate.endIndex] = [...target.position]
|
||||
data.path = path
|
||||
}
|
||||
// Steep rect / oval runs also re-derive their cross-section roll
|
||||
// so a riser's profile stays continuous through the fitting (same
|
||||
// continuity the draw tool computes; runs flipped to rect after
|
||||
// drawing never got it). Horizontal runs are left alone — their
|
||||
// roll-0 orientation is canonical and re-deriving it from a
|
||||
// possibly-stale riser roll would corrupt it.
|
||||
if (next.shape !== 'round' && mate.duct.shape !== 'round') {
|
||||
const away = mate.duct.path[mate.endIndex === 0 ? 1 : mate.duct.path.length - 2]
|
||||
const source = getDuctFittingPorts(next).find(
|
||||
(p) => p.id !== portId && p.id !== 'branch' && p.id !== 'branch2',
|
||||
)
|
||||
if (away && source) {
|
||||
const newDir = new Vector3(away[0] - end[0], away[1] - end[1], away[2] - end[2])
|
||||
if (newDir.lengthSq() >= 1e-10) {
|
||||
newDir.normalize()
|
||||
if (Math.abs(newDir.y) >= Math.SQRT1_2) {
|
||||
const srcMate = mates.get(source.id)
|
||||
const srcRoll = srcMate && srcMate.duct.shape !== 'round' ? srcMate.duct.roll : 0
|
||||
const srcDir = new Vector3(...source.direction)
|
||||
const roll = rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
||||
if (Math.abs(roll - mate.duct.roll) > 1e-6) data.roll = roll
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const metadata = refreshedAutoOffsetMetadata(
|
||||
mate.duct,
|
||||
mate.endIndex,
|
||||
target.position as Point,
|
||||
)
|
||||
if (metadata) data.metadata = metadata as DuctSegmentNode['metadata']
|
||||
if (Object.keys(data).length > 0) updates.push({ id: mate.duct.id, data })
|
||||
}
|
||||
return updates
|
||||
return [...updates, ...autoOffsetInvalidationUpdates(useScene.getState().nodes, next.id)]
|
||||
},
|
||||
|
||||
// Deleting an auto-inserted elbow restores the corner it replaced: both
|
||||
// mated runs were pulled back one leg onto its collars, with the
|
||||
// junction (the fitting's position) sitting exactly on the corner they
|
||||
// originally met at. Re-extend each mated endpoint back to that junction
|
||||
// so the L-shape returns to its pre-fitting length. Scoped to elbows —
|
||||
// tees / crosses split a trunk into two separate nodes, which can't be
|
||||
// re-joined by moving an endpoint.
|
||||
onDelete: (fitting, nodes) => {
|
||||
const invalidations = autoOffsetInvalidationUpdates(nodes, fitting.id)
|
||||
if (fitting.fittingType !== 'elbow') return invalidations
|
||||
const junction = new Vector3(...fitting.position)
|
||||
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
||||
for (const mate of matedDucts(fitting, nodes).values()) {
|
||||
const end = mate.duct.path[mate.endIndex]
|
||||
if (!end) continue
|
||||
const dx = end[0] - junction.x
|
||||
const dy = end[1] - junction.y
|
||||
const dz = end[2] - junction.z
|
||||
if (dx * dx + dy * dy + dz * dz < 1e-12) continue
|
||||
const path = mate.duct.path.map((p) => [...p] as Point)
|
||||
path[mate.endIndex] = [junction.x, junction.y, junction.z]
|
||||
const data: Partial<DuctSegmentNode> = { path }
|
||||
const metadata = refreshedAutoOffsetMetadata(mate.duct, mate.endIndex, [
|
||||
junction.x,
|
||||
junction.y,
|
||||
junction.z,
|
||||
])
|
||||
if (metadata) data.metadata = metadata as DuctSegmentNode['metadata']
|
||||
updates.push({ id: mate.duct.id, data })
|
||||
}
|
||||
return [...updates, ...invalidations]
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
@@ -170,7 +228,7 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
||||
key: 'angle',
|
||||
kind: 'number',
|
||||
unit: '°',
|
||||
min: 15,
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 15,
|
||||
visibleIf: (n) => n.fittingType === 'elbow',
|
||||
@@ -236,6 +294,13 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
||||
visibleIf: (n) =>
|
||||
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
|
||||
},
|
||||
{
|
||||
key: 'swapWidthHeight',
|
||||
kind: 'custom',
|
||||
component: DuctFittingSizeSwapEditor,
|
||||
visibleIf: (n) =>
|
||||
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
|
||||
},
|
||||
{
|
||||
key: 'shape2',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { NodePort } from '@pascal-app/core'
|
||||
import { Euler, Vector3 } from 'three'
|
||||
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import type { DuctFittingNode } from './schema'
|
||||
|
||||
const INCHES_TO_METERS = 0.0254
|
||||
|
||||
/**
|
||||
* Collar stub length in meters — how far each port sticks out from the
|
||||
* fitting's junction center. Scales with the duct so big trunks get
|
||||
|
||||
@@ -1,27 +1,299 @@
|
||||
'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_COLOR,
|
||||
EDITOR_LAYER,
|
||||
swallowNextClick,
|
||||
triggerSFX,
|
||||
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, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
LineSegments,
|
||||
type Object3D,
|
||||
OrthographicCamera,
|
||||
Plane,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
SphereGeometry,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import { autoOffsetInvalidationUpdates } from '../shared/auto-offset-tag'
|
||||
import {
|
||||
AXIS_VECTORS,
|
||||
cycleRotationAxis,
|
||||
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.14
|
||||
const RESIZE_HANDLE_GAP = 0.18
|
||||
const RESIZE_STEP_IN = 1
|
||||
const RESIZE_GUIDE_DASH = 0.07
|
||||
const RESIZE_GUIDE_GAP = 0.045
|
||||
const RESIZE_SPHERE_RADIUS = 0.065
|
||||
const RESIZE_HIT_RADIUS = 0.13
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
/** Rough body radius (meters) — the larger of the fitting's two collar reaches,
|
||||
* 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))
|
||||
}
|
||||
|
||||
/** The transform a drag frame writes onto the fitting. */
|
||||
type FittingTransform = { position?: Point; rotation?: Point }
|
||||
type FittingDimension = 'width' | 'height'
|
||||
|
||||
function fittingParameterPatch(node: DuctFittingNode): Partial<DuctFittingNode> {
|
||||
return {
|
||||
fittingType: node.fittingType,
|
||||
shape: node.shape,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
shape2: node.shape2,
|
||||
width2: node.width2,
|
||||
height2: node.height2,
|
||||
angle: node.angle,
|
||||
branchAngle: node.branchAngle,
|
||||
diameter: node.diameter,
|
||||
diameter2: node.diameter2,
|
||||
ductMaterial: node.ductMaterial,
|
||||
system: node.system,
|
||||
}
|
||||
}
|
||||
|
||||
function preserveFittingParameters(
|
||||
node: DuctFittingNode,
|
||||
data: Partial<DuctFittingNode>,
|
||||
): Partial<AnyNode> {
|
||||
return { ...fittingParameterPatch(node), ...data } as Partial<AnyNode>
|
||||
}
|
||||
|
||||
function canResizeRunProfile(node: DuctFittingNode): boolean {
|
||||
return (
|
||||
node.fittingType === 'transition' || (node.fittingType !== 'reducer' && node.shape !== 'round')
|
||||
)
|
||||
}
|
||||
|
||||
function dimensionBounds(dimension: FittingDimension): { min: number; max: number } {
|
||||
return dimension === 'width' ? { min: 4, max: 60 } : { min: 3, max: 40 }
|
||||
}
|
||||
|
||||
function closestAxisParameterToRay(
|
||||
axisOrigin: Vector3,
|
||||
axisDirection: Vector3,
|
||||
ray: Raycaster['ray'],
|
||||
) {
|
||||
const originToRay = axisOrigin.clone().sub(ray.origin)
|
||||
const b = axisDirection.dot(ray.direction)
|
||||
const d = axisDirection.dot(originToRay)
|
||||
const e = ray.direction.dot(originToRay)
|
||||
const denominator = 1 - b * b
|
||||
if (Math.abs(denominator) < 1e-6) return -d
|
||||
const axisParameter = (b * e - d) / denominator
|
||||
const rayParameter = e + b * axisParameter
|
||||
return rayParameter < 0 ? -d : axisParameter
|
||||
}
|
||||
|
||||
function DashedResizeGuide({ from, to }: { from: Point; to: Point }) {
|
||||
const line = useMemo(() => {
|
||||
const a = new Vector3(from[0], from[1], from[2])
|
||||
const b = new Vector3(to[0], to[1], to[2])
|
||||
const span = b.clone().sub(a)
|
||||
const length = span.length()
|
||||
const points: number[] = []
|
||||
if (length > 1e-4) {
|
||||
const dir = span.clone().normalize()
|
||||
let t = 0
|
||||
while (t < length) {
|
||||
const start = a.clone().addScaledVector(dir, t)
|
||||
const end = a.clone().addScaledVector(dir, Math.min(t + RESIZE_GUIDE_DASH, length))
|
||||
points.push(start.x, start.y, start.z, end.x, end.y, end.z)
|
||||
t += RESIZE_GUIDE_DASH + RESIZE_GUIDE_GAP
|
||||
}
|
||||
}
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(points), 3))
|
||||
const material = new LineBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
depthWrite: false,
|
||||
})
|
||||
const next = new LineSegments(geometry, material)
|
||||
next.frustumCulled = false
|
||||
next.layers.set(EDITOR_LAYER)
|
||||
next.renderOrder = 1002
|
||||
next.raycast = () => {}
|
||||
return next
|
||||
}, [from, to])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
line.geometry.dispose()
|
||||
;(line.material as LineBasicNodeMaterial).dispose()
|
||||
},
|
||||
[line],
|
||||
)
|
||||
|
||||
return <primitive object={line} />
|
||||
}
|
||||
|
||||
function ResizeSphereHandle({
|
||||
cursor,
|
||||
onPointerDown,
|
||||
position,
|
||||
}: {
|
||||
cursor: Cursor
|
||||
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||
position: Point
|
||||
}) {
|
||||
const { camera } = useThree()
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const sphereGeometry = useMemo(() => new SphereGeometry(RESIZE_SPHERE_RADIUS, 18, 12), [])
|
||||
const hitGeometry = useMemo(() => new SphereGeometry(RESIZE_HIT_RADIUS, 12, 8), [])
|
||||
const sphereMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.92,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const hitMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
sphereMaterial.opacity = hovered ? 1 : 0.92
|
||||
}, [sphereMaterial, hovered])
|
||||
useEffect(
|
||||
() => () => {
|
||||
hitGeometry.dispose()
|
||||
sphereGeometry.dispose()
|
||||
sphereMaterial.dispose()
|
||||
hitMaterial.dispose()
|
||||
},
|
||||
[hitGeometry, hitMaterial, sphereGeometry, sphereMaterial],
|
||||
)
|
||||
|
||||
const consumePress = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.stopPropagation()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
swallowNextClick()
|
||||
onPointerDown(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={position} scale={zoom}>
|
||||
<mesh
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={consumePress}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
setHovered(true)
|
||||
document.body.style.cursor = cursor
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
event.stopPropagation()
|
||||
setHovered(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}}
|
||||
/>
|
||||
<mesh geometry={sphereGeometry} material={sphereMaterial} renderOrder={1004} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* duct-segment selection rig. A CLICK-to-latch cube sits at the fitting center;
|
||||
* clicking it opens (click again to close) a cluster of:
|
||||
*
|
||||
* - **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.
|
||||
* - **Two profile cubes** on the fitting's visible side/top faces: resize
|
||||
* non-round fitting width and height without occupying the inside corner.
|
||||
*
|
||||
* 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. 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
|
||||
* 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 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
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -31,13 +303,586 @@ 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<Object3D | null>(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(<FittingHandles fitting={fitting} target={target} />, mount, undefined)
|
||||
}
|
||||
|
||||
const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: Object3D }) => {
|
||||
const { camera, gl } = useThree()
|
||||
const [frame, setFrame] = useState<Group | null>(null)
|
||||
// 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 [sideSign, setSideSign] = useState(1)
|
||||
|
||||
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
|
||||
}
|
||||
const sampleAxisParameter = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
axisOrigin: Vector3,
|
||||
axisDirection: Vector3,
|
||||
): number => closestAxisParameterToRay(axisOrigin, axisDirection, makeRay(clientX, clientY))
|
||||
/** 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])
|
||||
const axisToWorld = (origin: Point, axis: Vector3): Vector3 => {
|
||||
const originWorld = toWorld(origin)
|
||||
const tipWorld = frame
|
||||
? frame.localToWorld(new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z))
|
||||
: new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z)
|
||||
return tipWorld.sub(originWorld).normalize()
|
||||
}
|
||||
|
||||
/** 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 = (
|
||||
connectivity: PortConnectivity | null,
|
||||
transform: FittingTransform,
|
||||
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
||||
if (!connectivity) return []
|
||||
const preview = { ...(fitting as Record<string, unknown>), ...transform } as AnyNode
|
||||
const nodes = useScene.getState().nodes
|
||||
return resolveConnectivityUpdates(connectivity, preview)
|
||||
.filter((u) => nodes[u.id])
|
||||
.map((u) => {
|
||||
const node = nodes[u.id]
|
||||
if (node?.type !== 'duct-fitting') return u
|
||||
return {
|
||||
id: u.id,
|
||||
data: preserveFittingParameters(
|
||||
node as DuctFittingNode,
|
||||
u.data as Partial<DuctFittingNode>,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
(
|
||||
cursor: Cursor,
|
||||
makeCompute: (
|
||||
e: ThreeEvent<PointerEvent>,
|
||||
) => (event: PointerEvent) => FittingTransform | null,
|
||||
) =>
|
||||
(e: ThreeEvent<PointerEvent>) => {
|
||||
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<AnyNode> }[] => [
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, t as Partial<DuctFittingNode>),
|
||||
},
|
||||
...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 = () => {
|
||||
// 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
|
||||
// the final transform as one tracked change.
|
||||
const reverts: { id: AnyNodeId; data: Partial<AnyNode> }[] = (
|
||||
connectivity?.connections ?? []
|
||||
).map((conn) => {
|
||||
if (conn.kind !== 'rigid-node') {
|
||||
return { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }
|
||||
}
|
||||
const node = useScene.getState().nodes[conn.nodeId]
|
||||
return {
|
||||
id: conn.nodeId,
|
||||
data:
|
||||
node?.type === 'duct-fitting'
|
||||
? preserveFittingParameters(node as DuctFittingNode, {
|
||||
position: conn.startPosition as Point,
|
||||
})
|
||||
: ({ position: conn.startPosition } as Partial<AnyNode>),
|
||||
}
|
||||
})
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, {
|
||||
position: initialPosition,
|
||||
rotation: initialRotation,
|
||||
}),
|
||||
},
|
||||
...reverts.filter((u) => useScene.getState().nodes[u.id]),
|
||||
])
|
||||
resumeSceneHistory(useScene)
|
||||
if (current) {
|
||||
const scene = useScene.getState()
|
||||
scene.updateNodes([
|
||||
...buildBatch(current),
|
||||
...autoOffsetInvalidationUpdates(scene.nodes, fitting.id as AnyNodeId),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
// 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<PointerEvent>): ((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 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<PointerEvent>): ((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]),
|
||||
)
|
||||
let lastStep = Number.NaN
|
||||
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
|
||||
// Tick the rotate SFX each time a fresh snap step is crossed (snapped
|
||||
// turns only — a smooth Shift-drag has no discrete steps to mark).
|
||||
if (!event.shiftKey) {
|
||||
const step = Math.round(raw / ROTATE_STEP_RAD)
|
||||
if (step !== lastStep) {
|
||||
lastStep = step
|
||||
triggerSFX('sfx:item-rotate')
|
||||
}
|
||||
}
|
||||
const turn = new Quaternion().setFromAxisAngle(normal, delta)
|
||||
const euler = new Euler().setFromQuaternion(turn.multiply(startQuat))
|
||||
return { rotation: [euler.x, euler.y, euler.z] }
|
||||
}
|
||||
}
|
||||
|
||||
const beginDimensionDrag =
|
||||
(dimension: FittingDimension, axisLocal: Vector3, cursor: Cursor) =>
|
||||
(e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const baseValue = fitting[dimension]
|
||||
const initialPatch = { [dimension]: baseValue } as Partial<DuctFittingNode>
|
||||
const centerWorld = toWorld(fitting.position as Point)
|
||||
const axisWorld = axisToWorld(fitting.position as Point, axisLocal)
|
||||
const start = sampleAxisParameter(
|
||||
e.nativeEvent.clientX,
|
||||
e.nativeEvent.clientY,
|
||||
centerWorld,
|
||||
axisWorld,
|
||||
)
|
||||
const { min, max } = dimensionBounds(dimension)
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
setDragging(true)
|
||||
document.body.style.cursor = cursor
|
||||
let current: Partial<DuctFittingNode> | null = null
|
||||
let lastValue = Number.NaN
|
||||
|
||||
const apply = (patch: Partial<DuctFittingNode>) => {
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, patch),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const rawDeltaM =
|
||||
sampleAxisParameter(event.clientX, event.clientY, centerWorld, axisWorld) - start
|
||||
const deltaIn = (rawDeltaM / INCHES_TO_METERS) * 2
|
||||
const nextRaw = baseValue + deltaIn
|
||||
const nextValue = clamp(event.shiftKey ? nextRaw : snap(nextRaw, RESIZE_STEP_IN), min, max)
|
||||
if (nextValue === lastValue) return
|
||||
lastValue = nextValue
|
||||
current = { [dimension]: nextValue } as Partial<DuctFittingNode>
|
||||
if (!event.shiftKey) triggerSFX('sfx:grid-snap')
|
||||
apply(current)
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
swallowNextClick()
|
||||
cleanup()
|
||||
apply(initialPatch)
|
||||
resumeSceneHistory(useScene)
|
||||
if (current) apply(current)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
const extent = useMemo(() => fittingExtentM(fitting), [fitting])
|
||||
const p = fitting.position as Point
|
||||
const base = extent + ARROW_GAP
|
||||
const fittingRotation = useMemo(
|
||||
() => new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
|
||||
[fitting.rotation],
|
||||
)
|
||||
const profileAxes = useMemo(() => {
|
||||
const hingeAxis = new Vector3(0, 1, 0).applyEuler(fittingRotation).normalize()
|
||||
const sideAxis = new Vector3(0, 0, 1).applyEuler(fittingRotation).normalize()
|
||||
const hingeIsVertical = Math.abs(hingeAxis.y) >= Math.SQRT1_2
|
||||
const hingeDimension: FittingDimension = hingeIsVertical ? 'height' : 'width'
|
||||
const sideDimension: FittingDimension = hingeIsVertical ? 'width' : 'height'
|
||||
const hingeEntry = { key: hingeDimension, axis: hingeAxis }
|
||||
const sideEntry = { key: sideDimension, axis: sideAxis }
|
||||
return Math.abs(hingeAxis.dot(UP)) >= Math.abs(sideAxis.dot(UP))
|
||||
? { top: hingeEntry, side: sideEntry }
|
||||
: { top: sideEntry, side: hingeEntry }
|
||||
}, [fittingRotation])
|
||||
const topAxis = useMemo(() => {
|
||||
const axis = profileAxes.top.axis.clone()
|
||||
return axis.dot(UP) >= 0 ? axis : axis.multiplyScalar(-1)
|
||||
}, [profileAxes])
|
||||
const baseSideAxis = profileAxes.side.axis
|
||||
const sideAxis = useMemo(
|
||||
() => baseSideAxis.clone().multiplyScalar(sideSign),
|
||||
[baseSideAxis, sideSign],
|
||||
)
|
||||
useFrame(() => {
|
||||
if (!frame) return
|
||||
const cameraPosition = camera.getWorldPosition(new Vector3())
|
||||
const cameraLocal = frame.worldToLocal(cameraPosition)
|
||||
const toCamera = cameraLocal.sub(new Vector3(p[0], p[1], p[2]))
|
||||
const nextSign = baseSideAxis.dot(toCamera) >= 0 ? 1 : -1
|
||||
setSideSign((current) => (current === nextSign ? current : nextSign))
|
||||
})
|
||||
const resizeHandleBase = extent + RESIZE_HANDLE_GAP
|
||||
const resizeHandles: {
|
||||
key: FittingDimension
|
||||
axis: Vector3
|
||||
cursor: Cursor
|
||||
guideFrom: Point
|
||||
guideTo: Point
|
||||
position: Point
|
||||
}[] = canResizeRunProfile(fitting)
|
||||
? [
|
||||
{
|
||||
key: profileAxes.top.key,
|
||||
axis: topAxis,
|
||||
cursor: 'ns-resize',
|
||||
guideFrom: [
|
||||
p[0] + topAxis.x * resizeHandleBase,
|
||||
p[1] + topAxis.y * resizeHandleBase,
|
||||
p[2] + topAxis.z * resizeHandleBase,
|
||||
],
|
||||
guideTo: [
|
||||
p[0] + topAxis.x * Math.max(extent * 0.18, 0.04),
|
||||
p[1] + topAxis.y * Math.max(extent * 0.18, 0.04),
|
||||
p[2] + topAxis.z * Math.max(extent * 0.18, 0.04),
|
||||
],
|
||||
position: [
|
||||
p[0] + topAxis.x * resizeHandleBase,
|
||||
p[1] + topAxis.y * resizeHandleBase,
|
||||
p[2] + topAxis.z * resizeHandleBase,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: profileAxes.side.key,
|
||||
axis: sideAxis,
|
||||
cursor: 'ew-resize',
|
||||
guideFrom: [
|
||||
p[0] + sideAxis.x * resizeHandleBase,
|
||||
p[1] + sideAxis.y * resizeHandleBase,
|
||||
p[2] + sideAxis.z * resizeHandleBase,
|
||||
],
|
||||
guideTo: [
|
||||
p[0] + sideAxis.x * Math.max(extent * 0.18, 0.04),
|
||||
p[1] + sideAxis.y * Math.max(extent * 0.18, 0.04),
|
||||
p[2] + sideAxis.z * Math.max(extent * 0.18, 0.04),
|
||||
],
|
||||
position: [
|
||||
p[0] + sideAxis.x * resizeHandleBase,
|
||||
p[1] + sideAxis.y * resizeHandleBase,
|
||||
p[2] + sideAxis.z * resizeHandleBase,
|
||||
],
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
// 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 <group ref={setFrame} />
|
||||
}
|
||||
return (
|
||||
<group ref={setFrame}>
|
||||
<HandleCube active={open} onClick={() => setOpen((o) => !o)} position={p} />
|
||||
{!open &&
|
||||
resizeHandles.map((handle) => (
|
||||
<group key={handle.key}>
|
||||
<DashedResizeGuide from={handle.guideFrom} to={handle.guideTo} />
|
||||
<ResizeSphereHandle
|
||||
cursor={handle.cursor}
|
||||
onPointerDown={beginDimensionDrag(handle.key, handle.axis, handle.cursor)}
|
||||
position={handle.position}
|
||||
/>
|
||||
</group>
|
||||
))}
|
||||
{open && (
|
||||
<>
|
||||
{moveArrows.map((a) => (
|
||||
<MoveChevron
|
||||
cursor={a.cursor}
|
||||
key={a.key}
|
||||
onPointerDown={beginDrag(
|
||||
a.axis === 'y' ? 'ns-resize' : 'grabbing',
|
||||
moveCompute(a.axis),
|
||||
)}
|
||||
position={a.position}
|
||||
rotationY={a.rotationY}
|
||||
vertical={a.vertical}
|
||||
/>
|
||||
))}
|
||||
{rotateArcs.map((arc) => (
|
||||
<RotateArc
|
||||
key={arc.key}
|
||||
onPointerDown={beginDrag('grabbing', rotateCompute(arc.axis))}
|
||||
position={arc.position}
|
||||
rotation={arc.rotation}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default DuctFittingSelectionAffordance
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { type AnyNode, type NodeDefinition, useScene } from '@pascal-app/core'
|
||||
import { ductBodyPaint, ductBodySlots } from '../shared/duct-body-paint'
|
||||
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'
|
||||
@@ -71,6 +73,8 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
slots: () => ductBodySlots(),
|
||||
paint: ductBodyPaint,
|
||||
},
|
||||
|
||||
parametrics: ductSegmentParametrics,
|
||||
@@ -103,6 +107,7 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
n.insulated,
|
||||
n.insulationR,
|
||||
n.system,
|
||||
n.slots,
|
||||
]),
|
||||
|
||||
// Open run ends as typed ports — directions point outward along the
|
||||
@@ -147,6 +152,9 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
// `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).
|
||||
@@ -164,7 +172,7 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Click', label: 'Start segment' },
|
||||
{ key: 'Click again', label: 'Place it (locked to 45°)' },
|
||||
{ key: 'Click again', label: 'Place and continue' },
|
||||
{ key: 'Shift', label: 'Free angle' },
|
||||
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
|
||||
{ key: '[ / ]', label: 'Duct diameter down / up' },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { GeometryContext } from '@pascal-app/core'
|
||||
import {
|
||||
type ColorPreset,
|
||||
createSurfaceRoleMaterial,
|
||||
type RenderShading,
|
||||
resolveMaterialRef,
|
||||
resolveSlotDefaultMaterial,
|
||||
} from '@pascal-app/viewer'
|
||||
import {
|
||||
BoxGeometry,
|
||||
CatmullRomCurve3,
|
||||
CylinderGeometry,
|
||||
ExtrudeGeometry,
|
||||
Group,
|
||||
type Material,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
@@ -13,6 +22,7 @@ import {
|
||||
TubeGeometry,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { DUCT_BODY_SLOT_DEFAULT, DUCT_BODY_SLOT_ID } from '../shared/duct-body-paint'
|
||||
import type { DuctSegmentNode } from './schema'
|
||||
|
||||
export const INCHES_TO_METERS = 0.0254
|
||||
@@ -137,7 +147,7 @@ export function buildRectSection(
|
||||
end: Vector3,
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
name: string,
|
||||
roll = 0,
|
||||
): Mesh | null {
|
||||
@@ -200,7 +210,7 @@ export function buildOvalSection(
|
||||
end: Vector3,
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
name: string,
|
||||
roll = 0,
|
||||
): Mesh | null {
|
||||
@@ -226,7 +236,7 @@ export function buildSection(
|
||||
start: Vector3,
|
||||
end: Vector3,
|
||||
radius: number,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
name: string,
|
||||
): Mesh | null {
|
||||
const dir = new Vector3().subVectors(end, start)
|
||||
@@ -318,6 +328,7 @@ function helixRidgeFor(
|
||||
type DuctAppearance = {
|
||||
ductMaterial: 'sheet-metal' | 'spiral' | 'flex' | 'duct-board'
|
||||
system: 'supply' | 'return'
|
||||
slots?: Record<string, string>
|
||||
}
|
||||
|
||||
function getSystemTint(node: DuctAppearance): string {
|
||||
@@ -330,12 +341,25 @@ function getSystemTint(node: DuctAppearance): string {
|
||||
* metal. Shared with the fitting builder so connected runs and junctions
|
||||
* look like one piece.
|
||||
*/
|
||||
export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial {
|
||||
return new MeshStandardMaterial({
|
||||
color: '#ffffff',
|
||||
metalness: 0,
|
||||
roughness: 0.7,
|
||||
})
|
||||
export function createDuctMaterial(
|
||||
node: DuctAppearance,
|
||||
sceneMaterials?: GeometryContext['materials'],
|
||||
shading: RenderShading = 'rendered',
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
sceneTheme?: string,
|
||||
): Material {
|
||||
if (!textures) {
|
||||
return createSurfaceRoleMaterial('furnishing', colorPreset, undefined, sceneTheme)
|
||||
}
|
||||
|
||||
const slotRef = node.slots?.[DUCT_BODY_SLOT_ID]
|
||||
if (slotRef) {
|
||||
const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
return resolveSlotDefaultMaterial(DUCT_BODY_SLOT_DEFAULT, shading, 0.7)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,7 +378,14 @@ export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial
|
||||
* identity since the schema has no position field — the path itself is
|
||||
* absolute within the level).
|
||||
*/
|
||||
export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
export function buildDuctSegmentGeometry(
|
||||
node: DuctSegmentNode,
|
||||
ctx?: GeometryContext,
|
||||
shading: RenderShading = 'rendered',
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
sceneTheme?: string,
|
||||
): Group {
|
||||
const group = new Group()
|
||||
if (node.path.length < 2) return group
|
||||
|
||||
@@ -363,7 +394,14 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
const radius = (node.diameter * INCHES_TO_METERS) / 2
|
||||
const widthM = node.width * INCHES_TO_METERS
|
||||
const heightM = node.height * INCHES_TO_METERS
|
||||
const ductMaterial = createDuctMaterial(node)
|
||||
const ductMaterial = createDuctMaterial(
|
||||
node,
|
||||
ctx?.materials,
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
)
|
||||
|
||||
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
|
||||
|
||||
@@ -371,9 +409,10 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
half: number,
|
||||
rectW: number,
|
||||
rectH: number,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
namePrefix: string,
|
||||
endInsetM = 0,
|
||||
paintableBody = false,
|
||||
) => {
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
// Loop bounds + min(2) on the schema guarantee both points exist.
|
||||
@@ -396,7 +435,10 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
: isOval
|
||||
? buildOvalSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll)
|
||||
: buildSection(a, b, half, material, `${namePrefix}-section-${i}`)
|
||||
if (mesh) group.add(mesh)
|
||||
if (mesh) {
|
||||
if (paintableBody) mesh.userData.slotId = DUCT_BODY_SLOT_ID
|
||||
group.add(mesh)
|
||||
}
|
||||
}
|
||||
// Joint caps at interior points only (skip first and last — they're
|
||||
// open ends; equipment / terminal / fitting collars cap them). Rect
|
||||
@@ -410,11 +452,12 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
: new Mesh(new SphereGeometry(half, RADIAL_SEGMENTS, 12), material)
|
||||
joint.name = `${namePrefix}-joint-${i}`
|
||||
joint.position.copy(points[i] as Vector3)
|
||||
if (paintableBody) joint.userData.slotId = DUCT_BODY_SLOT_ID
|
||||
group.add(joint)
|
||||
}
|
||||
}
|
||||
|
||||
addRun(radius, widthM, heightM, ductMaterial, 'duct')
|
||||
addRun(radius, widthM, heightM, ductMaterial, 'duct', 0, true)
|
||||
|
||||
// Construction body detail: spiral winds its lock seam, flex its wire
|
||||
// helix (tight pitch — reads as corrugation) over each round section.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
analyzePortConnectivity,
|
||||
DuctSegmentNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
DragBoundingBox,
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
@@ -27,6 +29,13 @@ import {
|
||||
collectGhostAlignmentCandidates,
|
||||
resolveGhostAlignment,
|
||||
} from '../shared/ghost-alignment'
|
||||
import { DuctSegmentGhost, FittingGhost } from '../shared/mep-ghost'
|
||||
import { collectScenePorts, DUCT_PORT_SYSTEMS } from '../shared/ports'
|
||||
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
||||
import {
|
||||
planRunTranslationOffsets,
|
||||
type RunTranslationOffsetPlan,
|
||||
} from '../shared/run-translation-offset'
|
||||
import { rectSectionAxes } from './geometry'
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
@@ -106,6 +115,7 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
(node.metadata as Record<string, unknown>).isNew === true
|
||||
|
||||
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
|
||||
const [translationGhost, setTranslationGhost] = useState<RunTranslationOffsetPlan | null>(null)
|
||||
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
|
||||
const hasMovedRef = useRef(false)
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
@@ -138,6 +148,26 @@ 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 portConnectivity = existedAtStart
|
||||
? analyzePortConnectivity(node, useScene.getState().nodes)
|
||||
: null
|
||||
const scenePorts = existedAtStart
|
||||
? collectScenePorts({ excludeNodeId: nodeId, systems: DUCT_PORT_SYSTEMS })
|
||||
: []
|
||||
const nodesById = useScene.getState().nodes
|
||||
const profile = {
|
||||
shape: duct.shape,
|
||||
diameter: duct.diameter,
|
||||
width: duct.width,
|
||||
height: duct.height,
|
||||
}
|
||||
let lastTranslationPlan: RunTranslationOffsetPlan | null = null
|
||||
|
||||
const setPreview = (path: Vec3[]) => {
|
||||
previewPathRef.current = path
|
||||
setPreviewPath(path)
|
||||
@@ -177,12 +207,30 @@ 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)
|
||||
lastTranslationPlan =
|
||||
existedAtStart && portConnectivity
|
||||
? planRunTranslationOffsets({
|
||||
duct,
|
||||
translatedPath: nextPath,
|
||||
profile,
|
||||
connections: portConnectivity.connections,
|
||||
scenePorts,
|
||||
nodesById,
|
||||
})
|
||||
: null
|
||||
if (lastTranslationPlan) connectivity?.clear()
|
||||
else connectivity?.preview({ path: nextPath })
|
||||
setTranslationGhost(lastTranslationPlan)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -205,10 +253,44 @@ 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<AnyNode>)
|
||||
const translationPlan =
|
||||
portConnectivity &&
|
||||
planRunTranslationOffsets({
|
||||
duct,
|
||||
translatedPath: finalPath,
|
||||
profile,
|
||||
connections: portConnectivity.connections,
|
||||
scenePorts,
|
||||
nodesById,
|
||||
})
|
||||
if (translationPlan) {
|
||||
useScene.getState().applyNodeChanges({
|
||||
create: [...translationPlan.fittings, ...translationPlan.connectors].map((created) => ({
|
||||
node: created as AnyNode,
|
||||
parentId: node.parentId as AnyNodeId,
|
||||
})),
|
||||
update: [
|
||||
{ id: nodeId, data: { path: translationPlan.ductPath } as Partial<AnyNode> },
|
||||
...translationPlan.updates,
|
||||
],
|
||||
})
|
||||
} else {
|
||||
// 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<AnyNode> },
|
||||
...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 +302,8 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
setTranslationGhost(null)
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -231,14 +315,37 @@ 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()
|
||||
setTranslationGhost(null)
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (existedAtStart) setMeshHidden(false)
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -261,6 +368,16 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
{segments.map((seg, i) => (
|
||||
<GhostSegment a={seg.a} b={seg.b} duct={duct} key={`ghost-${i}`} />
|
||||
))}
|
||||
{translationGhost?.fittings.map((fitting) => (
|
||||
<FittingGhost fitting={fitting} key={`translation-fitting-${fitting.id}`} tint="valid" />
|
||||
))}
|
||||
{translationGhost?.connectors.map((connector) => (
|
||||
<DuctSegmentGhost
|
||||
duct={connector}
|
||||
key={`translation-connector-${connector.id}`}
|
||||
tint="valid"
|
||||
/>
|
||||
))}
|
||||
<DragBoundingBox
|
||||
centerY={0}
|
||||
nodeId={node.id}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,13 @@
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type CeilingNode,
|
||||
type DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getLevelHeight,
|
||||
sceneRegistry,
|
||||
getCeilingAt,
|
||||
getCeilingHeightAt,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -19,8 +21,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,
|
||||
@@ -30,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,
|
||||
@@ -40,17 +52,17 @@ import {
|
||||
type ScenePort,
|
||||
} from '../shared/ports'
|
||||
import { ductSegmentDefinition } from './definition'
|
||||
import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
||||
import { ductPortDiameterIn, rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
||||
|
||||
/**
|
||||
* One-segment-at-a-time placement tool for round duct segments.
|
||||
* Continuous placement tool for duct segments.
|
||||
*
|
||||
* Mouse-driven model:
|
||||
* - **First click** anchors the segment start (port snap joins onto an
|
||||
* existing run / fitting collar).
|
||||
* - **Second click** commits a two-point duct immediately and re-arms
|
||||
* the tool — no polyline accumulation, no finish gesture. Chain runs
|
||||
* by clicking again near the end you just placed (port snap).
|
||||
* - **Second click** commits a two-point duct immediately and keeps the
|
||||
* segment end anchored, so the next click continues the run like wall
|
||||
* drafting. No polyline accumulation, no finish gesture.
|
||||
* - **Auto-elbow**: when either end snapped onto another RUN's open
|
||||
* port at an angle (15–90°, vertical turns included), an elbow
|
||||
* fitting is minted at the joint and the duct pulls back to its
|
||||
@@ -69,9 +81,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
|
||||
@@ -94,6 +107,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
|
||||
@@ -163,6 +181,14 @@ function continuityRollFrom(port: ScenePort | null, newDir: Vector3): number | n
|
||||
return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
||||
}
|
||||
|
||||
function continuityRollForRun(
|
||||
startPort: ScenePort | null,
|
||||
endPort: ScenePort | null,
|
||||
dir: Vector3,
|
||||
): number {
|
||||
return continuityRollFrom(startPort, dir) ?? continuityRollFrom(endPort, dir) ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest typed port — duct run ends, fitting collars, anything whose
|
||||
* kind registers `def.ports` — within snap range of `point` on the XZ
|
||||
@@ -259,6 +285,209 @@ function projectToAngleLock(
|
||||
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
|
||||
}
|
||||
|
||||
/** The full set of nodes a drawn segment produces. The drawn `ducts`
|
||||
* (and any trunk `tails` from a tee / cross split) are previewed by the
|
||||
* duct ghost already; `fittings` are the auto-inserted elbow / tee /
|
||||
* cross nodes the ghost preview draws so the user sees them before the
|
||||
* commit. Shared by `commitSegment` and the live preview so what you see
|
||||
* is exactly what lands. */
|
||||
type DuctDrawPlan = {
|
||||
fittings: DuctFittingNode[]
|
||||
ducts: DuctSegmentNode[]
|
||||
tails: DuctSegmentNode[]
|
||||
updates: { id: AnyNode['id']; data: Partial<AnyNode> }[]
|
||||
}
|
||||
|
||||
const elbowPlanFor = (
|
||||
port: ScenePort | null,
|
||||
awayDir: [number, number, number],
|
||||
profile: DraftProfile,
|
||||
) => {
|
||||
if (!port) return null
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (owner?.type !== 'duct-segment') return null
|
||||
const plan = planElbowAtPort(port, awayDir, profile)
|
||||
if (!plan) return null
|
||||
// Trim the run's snapped endpoint back to the elbow's inlet collar.
|
||||
const path = owner.path.map((p) => [...p] as [number, number, number])
|
||||
const index = port.id === 'start' ? 0 : path.length - 1
|
||||
const neighbor = path[index === 0 ? 1 : index - 1]!
|
||||
const remaining = Math.hypot(
|
||||
plan.trimmedPortPoint[0] - neighbor[0],
|
||||
plan.trimmedPortPoint[1] - neighbor[1],
|
||||
plan.trimmedPortPoint[2] - neighbor[2],
|
||||
)
|
||||
// The trim must leave a real piece of the existing run AND not flip it.
|
||||
const original = path[index]!
|
||||
const originalLen = Math.hypot(
|
||||
original[0] - neighbor[0],
|
||||
original[1] - neighbor[1],
|
||||
original[2] - neighbor[2],
|
||||
)
|
||||
if (remaining < 0.08 || remaining >= originalLen) return null
|
||||
path[index] = plan.trimmedPortPoint
|
||||
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
|
||||
}
|
||||
|
||||
const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||||
if (!port) return null
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (owner?.type !== 'duct-fitting') return null
|
||||
return planElbowRealign(owner, port.id, awayDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure planner for a drawn duct segment: given its endpoints and what
|
||||
* each end snapped onto (an open port, or a run body for a tee / cross
|
||||
* tap), decide every node the commit creates / updates — auto-inserted
|
||||
* elbows / tees / crosses, the drawn run (split in two when it crosses a
|
||||
* trunk), trunk tails, and trim / realign updates. Reads the live scene
|
||||
* graph but mutates nothing, so the live preview can call it each frame
|
||||
* to ghost the fittings before the commit applies the identical plan.
|
||||
*/
|
||||
function planDuctDraw(
|
||||
start: [number, number, number],
|
||||
end: [number, number, number],
|
||||
startPort: ScenePort | null,
|
||||
startBody: RunBodyHit | null,
|
||||
endPort: ScenePort | null,
|
||||
endBody: RunBodyHit | null,
|
||||
profile: DraftProfile,
|
||||
): DuctDrawPlan | null {
|
||||
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
|
||||
if (length < 1e-4) return null
|
||||
const dir: [number, number, number] = [
|
||||
(end[0] - start[0]) / length,
|
||||
(end[1] - start[1]) / length,
|
||||
(end[2] - start[2]) / length,
|
||||
]
|
||||
|
||||
const startPlan = elbowPlanFor(startPort, dir, profile)
|
||||
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]], profile)
|
||||
const startRealign = startPlan ? null : realignPlanFor(startPort, dir)
|
||||
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||||
const trunkBody = startPlan ? null : startBody
|
||||
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
|
||||
const teePlan =
|
||||
trunkBody && trunkOwner?.type === 'duct-segment'
|
||||
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profile)
|
||||
: null
|
||||
const endTrunkBody = endPlan || endRealign ? null : endBody
|
||||
const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null
|
||||
const endTeePlan =
|
||||
endTrunkBody && endTrunkOwner?.type === 'duct-segment'
|
||||
? planTeeAtRunBody(endTrunkOwner, endTrunkBody, [-dir[0], -dir[1], -dir[2]], profile)
|
||||
: null
|
||||
let ductStart =
|
||||
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
|
||||
let ductEnd = endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
|
||||
const remaining = Math.hypot(
|
||||
ductEnd[0] - ductStart[0],
|
||||
ductEnd[1] - ductStart[1],
|
||||
ductEnd[2] - ductStart[2],
|
||||
)
|
||||
let plans = [startPlan, endPlan].filter((p) => p !== null)
|
||||
let tee = teePlan
|
||||
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
|
||||
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
|
||||
let realigns = [startRealign, endRealign].filter((p) => p !== null)
|
||||
|
||||
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M)
|
||||
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
|
||||
const crossTappedElsewhere =
|
||||
crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId
|
||||
let cross =
|
||||
crossHit && !crossTappedElsewhere && crossOwner?.type === 'duct-segment'
|
||||
? planCrossAtRunBody(crossOwner, crossHit, dir, profile)
|
||||
: null
|
||||
|
||||
if (remaining <= 0.08) {
|
||||
plans = []
|
||||
tee = null
|
||||
endTee = null
|
||||
realigns = []
|
||||
cross = null
|
||||
ductStart = start
|
||||
ductEnd = end
|
||||
}
|
||||
|
||||
// Rect / oval continuity: roll the new run's cross-section so its
|
||||
// profile stays continuous with whatever either end joined.
|
||||
let roll = 0
|
||||
if (profile.shape !== 'round') {
|
||||
const newDir = new Vector3(...dir)
|
||||
roll = continuityRollForRun(startPort, endPort, newDir)
|
||||
}
|
||||
|
||||
const defaults = ductSegmentDefinition.defaults()
|
||||
const toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}
|
||||
const makeDuct = (from: [number, number, number], to: [number, number, number]) =>
|
||||
DuctSegmentNode.parse({
|
||||
...defaults,
|
||||
...toolDefaults,
|
||||
name: profile.shape === 'rect' ? 'Trunk' : 'Duct run',
|
||||
path: [from, to],
|
||||
shape: profile.shape,
|
||||
diameter: profile.diameter,
|
||||
width: profile.width,
|
||||
height: profile.height,
|
||||
roll,
|
||||
})
|
||||
const ducts = cross
|
||||
? [
|
||||
dist2(ductStart, cross.branchCollarNear) > 0.08 * 0.08
|
||||
? makeDuct(ductStart, cross.branchCollarNear)
|
||||
: null,
|
||||
dist2(cross.branchCollarFar, ductEnd) > 0.08 * 0.08
|
||||
? makeDuct(cross.branchCollarFar, ductEnd)
|
||||
: null,
|
||||
].filter((d) => d !== null)
|
||||
: [makeDuct(ductStart, ductEnd)]
|
||||
|
||||
const fittings: DuctFittingNode[] = [
|
||||
...plans.map((p) => p.fitting),
|
||||
...(tee ? [tee.fitting] : []),
|
||||
...(endTee ? [endTee.fitting] : []),
|
||||
...(cross ? [cross.fitting] : []),
|
||||
]
|
||||
const tails: DuctSegmentNode[] = [
|
||||
...(tee ? [tee.trunkTail] : []),
|
||||
...(endTee ? [endTee.trunkTail] : []),
|
||||
...(cross ? [cross.trunkTail] : []),
|
||||
]
|
||||
const updates: { id: AnyNode['id']; data: Partial<AnyNode> }[] = [
|
||||
...plans.map((p) => p.trim),
|
||||
...(tee ? [tee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...realigns.map((p) => p.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
|
||||
]
|
||||
|
||||
return { fittings, ducts, tails, updates }
|
||||
}
|
||||
|
||||
function ductEndPort(duct: DuctSegmentNode, id: 'start' | 'end'): ScenePort | null {
|
||||
if (duct.path.length < 2) return null
|
||||
const index = id === 'start' ? 0 : duct.path.length - 1
|
||||
const neighborIndex = id === 'start' ? 1 : duct.path.length - 2
|
||||
const position = duct.path[index]!
|
||||
const neighbor = duct.path[neighborIndex]!
|
||||
const dx = position[0] - neighbor[0]
|
||||
const dy = position[1] - neighbor[1]
|
||||
const dz = position[2] - neighbor[2]
|
||||
const len = Math.hypot(dx, dy, dz)
|
||||
const direction: [number, number, number] =
|
||||
len < 1e-9 ? [1, 0, 0] : [dx / len, dy / len, dz / len]
|
||||
return {
|
||||
id,
|
||||
nodeId: duct.id,
|
||||
position,
|
||||
direction,
|
||||
diameter: ductPortDiameterIn(duct),
|
||||
system: duct.system,
|
||||
}
|
||||
}
|
||||
|
||||
const DuctSegmentTool = () => {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
@@ -285,12 +514,24 @@ 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
|
||||
// rather than a dot floating in space. Null when off-ceiling.
|
||||
const [hoverCeiling, setHoverCeiling] = useState<CeilingNode | null>(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)
|
||||
// What the in-flight cursor end currently snaps onto (port end, or a
|
||||
// run body for a tee / cross tap). Drives the auto-fitting GHOST so the
|
||||
// user sees the elbow / tee / cross the next click will mint.
|
||||
const [endSnap, setEndSnap] = useState<{ port: ScenePort | null; body: RunBodyHit | null }>({
|
||||
port: null,
|
||||
body: null,
|
||||
})
|
||||
// Mirror into refs so emitter callbacks (closing over the first render's
|
||||
// setState) read the latest values without re-subscribing.
|
||||
const draftRef = useRef(draftPoints)
|
||||
@@ -317,246 +558,63 @@ const DuctSegmentTool = () => {
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
/**
|
||||
* Auto-elbow gate: only joints onto another RUN's open end get a
|
||||
* fitting minted. Ports on fittings / equipment / terminals are
|
||||
* already proper connections — a duct mates straight onto those.
|
||||
*
|
||||
* The elbow's junction sits ON the drawn corner, so the existing run
|
||||
* must trim back one leg to make room (`trim` update). Plans that
|
||||
* would trim the run to (or past) nothing are dropped — that corner
|
||||
* stays a plain butt joint. Guards against the snapped node having
|
||||
* been deleted between clicks.
|
||||
*/
|
||||
const elbowPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||||
if (!port) return null
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (owner?.type !== 'duct-segment') return null
|
||||
const plan = planElbowAtPort(port, awayDir, profileRef.current)
|
||||
if (!plan) return null
|
||||
|
||||
// Trim the run's snapped endpoint back to the elbow's inlet collar.
|
||||
const path = owner.path.map((p) => [...p] as [number, number, number])
|
||||
const index = port.id === 'start' ? 0 : path.length - 1
|
||||
const neighbor = path[index === 0 ? 1 : index - 1]!
|
||||
const remaining = Math.hypot(
|
||||
plan.trimmedPortPoint[0] - neighbor[0],
|
||||
plan.trimmedPortPoint[1] - neighbor[1],
|
||||
plan.trimmedPortPoint[2] - neighbor[2],
|
||||
)
|
||||
// The trim must leave a real piece of the existing run AND not flip
|
||||
// it (trimmed point past the neighbor) — otherwise skip the fitting.
|
||||
const original = path[index]!
|
||||
const originalLen = Math.hypot(
|
||||
original[0] - neighbor[0],
|
||||
original[1] - neighbor[1],
|
||||
original[2] - neighbor[2],
|
||||
)
|
||||
if (remaining < 0.08 || remaining >= originalLen) return null
|
||||
path[index] = plan.trimmedPortPoint
|
||||
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Realign gate: the snapped port belongs to an existing ELBOW's open
|
||||
* collar — re-aim that elbow (junction + mated collar fixed, free
|
||||
* collar swings to the drawn direction). Null when the owner isn't
|
||||
* an elbow or the required turn leaves the 15–90° range.
|
||||
*/
|
||||
const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||||
if (!port) return null
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (owner?.type !== 'duct-fitting') return null
|
||||
return planElbowRealign(owner, port.id, awayDir)
|
||||
}
|
||||
|
||||
// One segment per gesture: first click anchors the start, second
|
||||
// click commits a two-point duct immediately. No selection switch —
|
||||
// the tool stays armed so the next click starts the next segment
|
||||
// (port snap joins it onto the end just committed).
|
||||
// Continuous chain: first click anchors the start, each following
|
||||
// click commits one two-point duct and uses that duct's far end as
|
||||
// the next anchor. No selection switch or finish gesture.
|
||||
//
|
||||
// When an end of the segment snapped onto another run's open port at
|
||||
// an angle, an elbow fitting is minted at that joint and the duct is
|
||||
// pulled back to the elbow's outlet collar — corners get real
|
||||
// fittings instead of butt joints.
|
||||
// All the auto-fitting decisions (elbow / tee / cross) live in the
|
||||
// shared `planDuctDraw` so the live ghost previews exactly what this
|
||||
// commit applies.
|
||||
const commitSegment = (
|
||||
start: [number, number, number],
|
||||
end: [number, number, number],
|
||||
endPort: ScenePort | null = null,
|
||||
endBody: RunBodyHit | null = null,
|
||||
) => {
|
||||
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
|
||||
if (length < 1e-4) return
|
||||
const dir: [number, number, number] = [
|
||||
(end[0] - start[0]) / length,
|
||||
(end[1] - start[1]) / length,
|
||||
(end[2] - start[2]) / length,
|
||||
]
|
||||
|
||||
const startPlan = elbowPlanFor(startPortRef.current, dir)
|
||||
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||||
// Existing-fitting joints: re-aim the elbow whose collar was hit so
|
||||
// it faces the drawn run instead of leaving a mismatched butt joint.
|
||||
const startRealign = startPlan ? null : realignPlanFor(startPortRef.current, dir)
|
||||
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||||
// Tee tap: the start snapped onto a run's BODY (not an end port) —
|
||||
// split the trunk and branch from the tee's collar.
|
||||
const trunkBody = startPlan ? null : startBodyRef.current
|
||||
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
|
||||
const teePlan =
|
||||
trunkBody && trunkOwner?.type === 'duct-segment'
|
||||
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profileRef.current)
|
||||
: null
|
||||
// End tee tap: the END landed on a run's BODY — split that trunk and
|
||||
// the new duct ends at the tee's branch collar. The branch leaves
|
||||
// toward the drawn run (back along -dir, since dir points start→end).
|
||||
const endTrunkBody = endPlan || endRealign ? null : endBody
|
||||
const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null
|
||||
const endTeePlan =
|
||||
endTrunkBody && endTrunkOwner?.type === 'duct-segment'
|
||||
? planTeeAtRunBody(
|
||||
endTrunkOwner,
|
||||
endTrunkBody,
|
||||
[-dir[0], -dir[1], -dir[2]],
|
||||
profileRef.current,
|
||||
)
|
||||
: null
|
||||
let ductStart =
|
||||
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
|
||||
let ductEnd =
|
||||
endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
|
||||
// The collar pull-back must leave a real piece of duct between the
|
||||
// fittings; if not, fall back to the plain joint.
|
||||
const remaining = Math.hypot(
|
||||
ductEnd[0] - ductStart[0],
|
||||
ductEnd[1] - ductStart[1],
|
||||
ductEnd[2] - ductStart[2],
|
||||
const plan = planDuctDraw(
|
||||
start,
|
||||
end,
|
||||
startPortRef.current,
|
||||
startBodyRef.current,
|
||||
endPort,
|
||||
endBody,
|
||||
profileRef.current,
|
||||
)
|
||||
let plans = [startPlan, endPlan].filter((p) => p !== null)
|
||||
let tee = teePlan
|
||||
// Both ends tapping the SAME trunk would split one polyline twice in
|
||||
// a single change (conflicting updates + double tail) — drop the end
|
||||
// tee in that rare case and let the end butt-join instead.
|
||||
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
|
||||
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
|
||||
let realigns = [startRealign, endRealign].filter((p) => p !== null)
|
||||
|
||||
// Cross tap: the drawn run passes straight THROUGH a trunk's body
|
||||
// (interior crossing, not an end touch). Split that trunk and the
|
||||
// drawn duct into two halves meeting the cross's opposed branch
|
||||
// collars. Skip a run already tapped by a start / end tee so one
|
||||
// polyline isn't split twice in a single change.
|
||||
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M)
|
||||
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
|
||||
const crossTappedElsewhere =
|
||||
crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId
|
||||
let cross =
|
||||
crossHit && !crossTappedElsewhere && crossOwner?.type === 'duct-segment'
|
||||
? planCrossAtRunBody(crossOwner, crossHit, dir, profileRef.current)
|
||||
: null
|
||||
|
||||
if (remaining <= 0.08) {
|
||||
plans = []
|
||||
tee = null
|
||||
endTee = null
|
||||
realigns = []
|
||||
cross = null
|
||||
ductStart = start
|
||||
ductEnd = end
|
||||
}
|
||||
|
||||
// Rect / oval continuity: roll the new run's cross-section so its
|
||||
// profile stays continuous with whatever either end joined — run
|
||||
// end or fitting collar, turn or straight continuation (see
|
||||
// `continuityRollFrom`). The start joint wins if both ends join.
|
||||
let roll = 0
|
||||
if (profileRef.current.shape !== 'round') {
|
||||
const newDir = new Vector3(...dir)
|
||||
roll =
|
||||
continuityRollFrom(startPortRef.current, newDir) ??
|
||||
continuityRollFrom(endPort, newDir) ??
|
||||
0
|
||||
}
|
||||
|
||||
const defaults = ductSegmentDefinition.defaults()
|
||||
const toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}
|
||||
const makeDuct = (from: [number, number, number], to: [number, number, number]) =>
|
||||
DuctSegmentNode.parse({
|
||||
...defaults,
|
||||
...toolDefaults,
|
||||
name: profileRef.current.shape === 'rect' ? 'Trunk' : 'Duct run',
|
||||
path: [from, to],
|
||||
shape: profileRef.current.shape,
|
||||
diameter: profileRef.current.diameter,
|
||||
width: profileRef.current.width,
|
||||
height: profileRef.current.height,
|
||||
roll,
|
||||
})
|
||||
// A cross splits the drawn run into two halves that meet its opposed
|
||||
// branch collars; otherwise it's one duct end-to-end. Degenerate
|
||||
// halves (the crossing too near an end) are dropped.
|
||||
const ducts = cross
|
||||
? [
|
||||
dist2(ductStart, cross.branchCollarNear) > 0.08 * 0.08
|
||||
? makeDuct(ductStart, cross.branchCollarNear)
|
||||
: null,
|
||||
dist2(cross.branchCollarFar, ductEnd) > 0.08 * 0.08
|
||||
? makeDuct(cross.branchCollarFar, ductEnd)
|
||||
: null,
|
||||
].filter((d) => d !== null)
|
||||
: [makeDuct(ductStart, ductEnd)]
|
||||
if (!plan) return
|
||||
// One atomic change: trim / split the joined runs, create the
|
||||
// fittings + the new duct. Single undo step.
|
||||
useScene.getState().applyNodeChanges({
|
||||
create: [
|
||||
...plans.map((plan) => ({ node: plan.fitting, parentId: activeLevelId })),
|
||||
...(tee
|
||||
? [
|
||||
{ node: tee.fitting, parentId: activeLevelId },
|
||||
{ node: tee.trunkTail, parentId: activeLevelId },
|
||||
]
|
||||
: []),
|
||||
...(endTee
|
||||
? [
|
||||
{ node: endTee.fitting, parentId: activeLevelId },
|
||||
{ node: endTee.trunkTail, parentId: activeLevelId },
|
||||
]
|
||||
: []),
|
||||
...(cross
|
||||
? [
|
||||
{ node: cross.fitting, parentId: activeLevelId },
|
||||
{ node: cross.trunkTail, parentId: activeLevelId },
|
||||
]
|
||||
: []),
|
||||
...ducts.map((node) => ({ node, parentId: activeLevelId })),
|
||||
],
|
||||
update: [
|
||||
...plans.map((plan) => plan.trim),
|
||||
...(tee ? [tee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...realigns.map((plan) => plan.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
|
||||
...plan.fittings.map((node) => ({ node, parentId: activeLevelId })),
|
||||
...plan.tails.map((node) => ({ node, parentId: activeLevelId })),
|
||||
...plan.ducts.map((node) => ({ node, parentId: activeLevelId })),
|
||||
],
|
||||
update: plan.updates,
|
||||
})
|
||||
const nextDuct = plan.ducts.at(-1)
|
||||
const nextStart = nextDuct ? nextDuct.path[nextDuct.path.length - 1]! : end
|
||||
const nextPort = nextDuct ? ductEndPort(nextDuct, 'end') : endPort
|
||||
triggerSFX('sfx:item-place')
|
||||
setDraftPoints([])
|
||||
setDraftPoints([nextStart])
|
||||
setSnapTarget(null)
|
||||
startPortRef.current = null
|
||||
startBodyRef.current = null
|
||||
setEndSnap({ port: null, body: null })
|
||||
startPortRef.current = nextPort
|
||||
startBodyRef.current = nextPort ? null : endBody
|
||||
altAnchorRef.current = null
|
||||
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 +629,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 +659,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 +701,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 +747,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 +768,16 @@ const DuctSegmentTool = () => {
|
||||
clearDrawAlignment()
|
||||
setCursorPos(point)
|
||||
setSnapTarget(null)
|
||||
setEndSnap({ port: null, body: null })
|
||||
updateHoverCeiling(point[0], point[2])
|
||||
return
|
||||
}
|
||||
}
|
||||
const { point, snapped } = resolveAlignedPoint(event)
|
||||
const { point, snapped, port, body } = resolveAlignedPoint(event)
|
||||
setCursorPos(point)
|
||||
setSnapTarget(snapped)
|
||||
setEndSnap({ port, body: port ? null : body })
|
||||
updateHoverCeiling(point[0], point[2])
|
||||
}
|
||||
|
||||
const onClick = (event: GridEvent) => {
|
||||
@@ -783,12 +864,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 +890,8 @@ const DuctSegmentTool = () => {
|
||||
setDraftPoints([])
|
||||
setCursorPos(null)
|
||||
setSnapTarget(null)
|
||||
setEndSnap({ port: null, body: null })
|
||||
setHoverCeiling(null)
|
||||
startPortRef.current = null
|
||||
startBodyRef.current = null
|
||||
}
|
||||
@@ -838,6 +923,22 @@ const DuctSegmentTool = () => {
|
||||
previewSegments.push({ a: last, b: cursorPos })
|
||||
}
|
||||
|
||||
// Ghost the auto-inserted fittings (elbow / tee / cross) the next click
|
||||
// will mint, by running the SAME planner the commit uses against the
|
||||
// in-flight endpoints. Skipped in Alt-vertical mode (no XZ tap there).
|
||||
const ghostFittings =
|
||||
last && cursorPos && !altActive
|
||||
? (planDuctDraw(
|
||||
last,
|
||||
cursorPos,
|
||||
startPortRef.current,
|
||||
startBodyRef.current,
|
||||
endSnap.port,
|
||||
endSnap.body,
|
||||
profile,
|
||||
)?.fittings ?? [])
|
||||
: []
|
||||
|
||||
// Wall-style dimension pill above the cursor: absolute world coords before
|
||||
// the first point, signed per-axis deltas from the last placed point while
|
||||
// a segment is in flight. The actively-driven axis is emphasised — Y in
|
||||
@@ -868,45 +969,71 @@ 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 (
|
||||
<LevelOffsetGroup>
|
||||
{/* 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 && <CeilingHighlight ceiling={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 && (
|
||||
<>
|
||||
<CursorSphere position={cursorPos} ref={cursorRef} />
|
||||
{/* 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 ? (
|
||||
<CursorSphere
|
||||
color={snapTarget ? SNAP_CURSOR_COLOR : undefined}
|
||||
dotAtTip
|
||||
height={cursorElevation}
|
||||
position={cursorGround}
|
||||
ref={cursorRef}
|
||||
/>
|
||||
) : (
|
||||
<CursorSphere
|
||||
color={snapTarget ? SNAP_CURSOR_COLOR : undefined}
|
||||
position={cursorPos}
|
||||
ref={cursorRef}
|
||||
/>
|
||||
)}
|
||||
{pillParts && (
|
||||
<group position={cursorPos}>
|
||||
<Html
|
||||
center
|
||||
position={[0, 0.35, 0]}
|
||||
position={[0, 1.45, 0]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{ceilingMode && !last && (
|
||||
<div className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-3 py-0.5 text-[10px] text-muted-foreground shadow-sm backdrop-blur">
|
||||
Ceiling · C to toggle
|
||||
</div>
|
||||
)}
|
||||
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<mesh layers={EDITOR_LAYER} position={snapTarget}>
|
||||
<sphereGeometry args={[0.12, 24, 16]} />
|
||||
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
|
||||
</mesh>
|
||||
)}
|
||||
{/* Committed point pips */}
|
||||
{draftPoints.map((p, i) => (
|
||||
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
|
||||
@@ -919,25 +1046,112 @@ const DuctSegmentTool = () => {
|
||||
<PreviewSegment
|
||||
a={seg.a}
|
||||
b={seg.b}
|
||||
endPort={endSnap.port}
|
||||
key={`seg-${i}`}
|
||||
profile={profile}
|
||||
startPort={startPortRef.current}
|
||||
/>
|
||||
))}
|
||||
{/* Auto-fitting ghosts — the elbow / tee / cross the next click mints. */}
|
||||
{ghostFittings.map((fitting) => (
|
||||
<FittingGhost fitting={fitting} key={fitting.id} />
|
||||
))}
|
||||
</LevelOffsetGroup>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Array<[number, number]>>,
|
||||
): 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 (
|
||||
<group position={[0, y, 0]}>
|
||||
<mesh geometry={geometry} layers={EDITOR_LAYER} renderOrder={1}>
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthWrite={false}
|
||||
opacity={0.15}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
{outline && (
|
||||
<line>
|
||||
<bufferGeometry
|
||||
ref={(g) => {
|
||||
if (g) g.setFromPoints(outline)
|
||||
}}
|
||||
/>
|
||||
<lineBasicMaterial color="#818cf8" opacity={0.6} transparent />
|
||||
</line>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewSegment({
|
||||
a,
|
||||
b,
|
||||
profile,
|
||||
startPort,
|
||||
endPort,
|
||||
}: {
|
||||
a: [number, number, number]
|
||||
b: [number, number, number]
|
||||
profile: DraftProfile
|
||||
startPort: ScenePort | null
|
||||
endPort: ScenePort | null
|
||||
}) {
|
||||
const start = new Vector3(...a)
|
||||
const end = new Vector3(...b)
|
||||
@@ -959,7 +1173,7 @@ function PreviewSegment({
|
||||
if (!m) return
|
||||
// Same basis AND roll as the commit will use, so the ghost
|
||||
// shows the orientation that actually lands.
|
||||
const roll = continuityRollFrom(startPort, dir) ?? 0
|
||||
const roll = continuityRollForRun(startPort, endPort, dir)
|
||||
const { width: x, height: z } = rectSectionAxes(dir, roll)
|
||||
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
|
||||
}}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type EyebrowVentNode,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
@@ -21,8 +22,14 @@ import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
snapRelativeRoofDragTarget,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfaceNodePlacementGuides,
|
||||
snapRoofSurfaceNodeTarget,
|
||||
} from '../shared/roof-surface-placement-guides'
|
||||
import EyebrowVentPreview from './preview'
|
||||
|
||||
/**
|
||||
@@ -71,10 +78,21 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
lastSnap = null
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
|
||||
const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => {
|
||||
const rawTarget = roofDrag.resolve(event)
|
||||
if (!rawTarget) return null
|
||||
return snapRoofSurfaceNodeTarget({
|
||||
target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true),
|
||||
node,
|
||||
bypass: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
const target = resolveSnappedTarget(event)
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
@@ -101,12 +119,18 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
publishRoofSurfaceNodePlacementGuides({
|
||||
roof: event.node as RoofNode,
|
||||
segment: target.segment,
|
||||
center: [target.localX, target.localY, target.localZ],
|
||||
node,
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
@@ -147,6 +171,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
if (obj) obj.visible = true
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -165,6 +190,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
@@ -184,6 +210,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
@@ -213,6 +240,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
clearRoofSurfacePlacementGuides()
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
|
||||
import { createRelativeRoofDrag, snapRelativeRoofDragTarget } from '../shared/relative-roof-drag'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfaceNodePlacementGuides,
|
||||
} from '../shared/roof-surface-placement-guides'
|
||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||
import GutterPreview from './preview'
|
||||
|
||||
@@ -83,11 +87,13 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
lastTarget = null
|
||||
lastSnap = null
|
||||
setTarget(null)
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
|
||||
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return null
|
||||
const rawTarget = roofDrag.resolve(event)
|
||||
if (!rawTarget) return null
|
||||
const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true)
|
||||
return {
|
||||
segment: target.segment,
|
||||
snap: resolveEaveSnap(target.segment, target.localX, target.localZ),
|
||||
@@ -131,6 +137,13 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
},
|
||||
snap,
|
||||
})
|
||||
publishRoofSurfaceNodePlacementGuides({
|
||||
roof,
|
||||
segment: target.segment,
|
||||
center: [snap.eaveX, snap.eaveY, snap.eaveZ],
|
||||
node: { ...node, rotation: snap.rotation },
|
||||
mode: 'linear-edge',
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
@@ -178,6 +191,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
if (obj) obj.visible = true
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -196,6 +210,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
@@ -215,6 +230,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
@@ -244,6 +260,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
clearRoofSurfacePlacementGuides()
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<GutterPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => setTarget(null)}
|
||||
onInvalidTarget={() => {
|
||||
setTarget(null)
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}}
|
||||
/>
|
||||
{activeBuildingId && target && (
|
||||
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
||||
|
||||
@@ -379,7 +379,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
mesh.castShadow = !hasGlass
|
||||
mesh.receiveShadow = !hasGlass
|
||||
}
|
||||
}, [ref, scene, shading, textures, colorPreset, node.slots, sceneMaterials])
|
||||
}, [shading, textures, colorPreset, node.slots, sceneMaterials])
|
||||
|
||||
const interactive = interactiveRef.current
|
||||
const animEffect =
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { planLinesetConnect } from './connect'
|
||||
import type { LinesetNode } from './schema'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
/** Minimal stand-in — the planner only reads `id` and `path`. */
|
||||
function line(id: string, path: Point[]): LinesetNode {
|
||||
return { id, path } as unknown as LinesetNode
|
||||
}
|
||||
|
||||
describe('planLinesetConnect', () => {
|
||||
test('no shared endpoint → create', () => {
|
||||
const plan = planLinesetConnect(
|
||||
[
|
||||
line('a', [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
]),
|
||||
],
|
||||
[5, 0, 0],
|
||||
[6, 0, 0],
|
||||
)
|
||||
expect(plan).toEqual({
|
||||
kind: 'create',
|
||||
path: [
|
||||
[5, 0, 0],
|
||||
[6, 0, 0],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('new start meets run end → extend, old end becomes interior', () => {
|
||||
const a = line('a', [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
])
|
||||
const plan = planLinesetConnect([a], [1, 0, 0], [1, 0, 2])
|
||||
expect(plan).toEqual({
|
||||
kind: 'extend',
|
||||
id: 'a',
|
||||
path: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
[1, 0, 2],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('new start meets run start → extend, run reversed so join is interior', () => {
|
||||
const a = line('a', [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
])
|
||||
const plan = planLinesetConnect([a], [0, 0, 0], [0, 0, 2])
|
||||
expect(plan).toEqual({
|
||||
kind: 'extend',
|
||||
id: 'a',
|
||||
path: [
|
||||
[1, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 2],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('new end meets a run → extend, new segment leads', () => {
|
||||
const a = line('a', [
|
||||
[1, 0, 0],
|
||||
[2, 0, 0],
|
||||
])
|
||||
const plan = planLinesetConnect([a], [1, 0, 3], [1, 0, 0])
|
||||
expect(plan).toEqual({
|
||||
kind: 'extend',
|
||||
id: 'a',
|
||||
path: [
|
||||
[1, 0, 3],
|
||||
[1, 0, 0],
|
||||
[2, 0, 0],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('both ends meet distinct runs → bridge, second run absorbed', () => {
|
||||
const a = line('a', [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
])
|
||||
const b = line('b', [
|
||||
[1, 0, 5],
|
||||
[2, 0, 5],
|
||||
])
|
||||
const plan = planLinesetConnect([a, b], [1, 0, 0], [1, 0, 5])
|
||||
expect(plan).toEqual({
|
||||
kind: 'bridge',
|
||||
id: 'a',
|
||||
deleteId: 'b',
|
||||
path: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
[1, 0, 5],
|
||||
[2, 0, 5],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('both ends meet the SAME run → not a bridge (extends at start)', () => {
|
||||
const a = line('a', [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
])
|
||||
const plan = planLinesetConnect([a], [0, 0, 0], [1, 0, 0])
|
||||
expect(plan.kind).toBe('extend')
|
||||
})
|
||||
|
||||
test('float drift within tolerance still coincides', () => {
|
||||
const a = line('a', [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
])
|
||||
const plan = planLinesetConnect([a], [1.0000001, 0, 0], [1, 0, 2])
|
||||
expect(plan.kind).toBe('extend')
|
||||
})
|
||||
})
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { LinesetNode } from './schema'
|
||||
|
||||
type Point = [number, number, number]
|
||||
type LinesetId = LinesetNode['id']
|
||||
|
||||
/** Coincidence tolerance (meters) for folding endpoints into one run. The
|
||||
* draw tool snaps onto an existing run's endpoint exactly, so this only
|
||||
* needs to absorb float drift, not user aim. */
|
||||
const COINCIDENT_EPS_M = 1e-3
|
||||
|
||||
function samePoint(a: Point, b: Point): boolean {
|
||||
return (
|
||||
Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
|
||||
Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
|
||||
Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
|
||||
)
|
||||
}
|
||||
|
||||
/** Which terminal of `line` coincides with `p`, if either. */
|
||||
function matchEnd(line: LinesetNode, p: Point): 'start' | 'end' | null {
|
||||
const path = line.path as Point[]
|
||||
if (samePoint(path[0]!, p)) return 'start'
|
||||
if (samePoint(path[path.length - 1]!, p)) return 'end'
|
||||
return null
|
||||
}
|
||||
|
||||
/** First lineset whose start or end coincides with `p`. */
|
||||
function findConnection(
|
||||
existing: LinesetNode[],
|
||||
p: Point,
|
||||
): { line: LinesetNode; side: 'start' | 'end' } | null {
|
||||
for (const line of existing) {
|
||||
if (line.path.length < 2) continue
|
||||
const side = matchEnd(line, p)
|
||||
if (side) return { line, side }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Path re-ordered so the connecting terminal is its LAST point. */
|
||||
function endLast(path: Point[], side: 'start' | 'end'): Point[] {
|
||||
return side === 'end' ? path : [...path].reverse()
|
||||
}
|
||||
|
||||
/** Path re-ordered so the connecting terminal is its FIRST point. */
|
||||
function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
|
||||
return side === 'start' ? path : [...path].reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of committing a new `start`→`end` segment against the existing
|
||||
* lineset runs on the same level:
|
||||
* - `create` — no shared endpoint; place a fresh standalone run.
|
||||
* - `extend` — one end lands on run `id`; grow that run's path so the old
|
||||
* terminal becomes an interior point (the geometry miters it).
|
||||
* - `bridge` — both ends land on two *different* runs; weld them plus the
|
||||
* new segment into one path on `id` and delete the absorbed `deleteId`.
|
||||
*/
|
||||
export type LinesetConnectPlan =
|
||||
| { kind: 'create'; path: Point[] }
|
||||
| { kind: 'extend'; id: LinesetId; path: Point[] }
|
||||
| { kind: 'bridge'; id: LinesetId; path: Point[]; deleteId: LinesetId }
|
||||
|
||||
/**
|
||||
* Decide how a freshly drawn `start`→`end` segment folds into existing
|
||||
* lineset runs that share an endpoint coordinate. Pure: returns a plan, the
|
||||
* caller mutates the scene. Coords are level-local, so `existing` must be
|
||||
* pre-filtered to the segment's level.
|
||||
*/
|
||||
export function planLinesetConnect(
|
||||
existing: LinesetNode[],
|
||||
start: Point,
|
||||
end: Point,
|
||||
): LinesetConnectPlan {
|
||||
const atStart = findConnection(existing, start)
|
||||
const atEnd = findConnection(existing, end)
|
||||
|
||||
// Both ends meet distinct runs → weld the three into one path.
|
||||
if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
|
||||
const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
|
||||
const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
|
||||
return {
|
||||
kind: 'bridge',
|
||||
id: atStart.line.id,
|
||||
path: [...left, ...right],
|
||||
deleteId: atEnd.line.id,
|
||||
}
|
||||
}
|
||||
if (atStart) {
|
||||
const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
|
||||
return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
|
||||
}
|
||||
if (atEnd) {
|
||||
const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
|
||||
return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
|
||||
}
|
||||
return { kind: 'create', path: [start, end] }
|
||||
}
|
||||
@@ -46,8 +46,12 @@ function buildRun(
|
||||
*
|
||||
* One line per node — what the ghost previews is exactly what commits. To run
|
||||
* the suction line beside the liquid line, draw them as two separate linesets
|
||||
* rather than rendering both together off one path. Joint spheres cap interior
|
||||
* corners so turns read as continuous pipe.
|
||||
* rather than rendering both together off one path.
|
||||
*
|
||||
* Each line is a standalone two-point node (no fitting system, unlike ducts),
|
||||
* so a sphere caps BOTH endpoints. On a free end it just rounds the cap; where
|
||||
* two segments share a coordinate the coincident spheres fill the miter gap, so
|
||||
* the turn reads as continuous pipe.
|
||||
*
|
||||
* Children are level-local meters; `<ParametricNodeRenderer>` owns the
|
||||
* node transform (identity today — the path is absolute within the level).
|
||||
@@ -87,8 +91,10 @@ export function buildLinesetGeometry(node: LinesetNode): Group {
|
||||
}
|
||||
}
|
||||
|
||||
// Joint caps at interior corners so turns read as continuous pipe.
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
// Spherical caps at every point. Interior corners read as continuous pipe;
|
||||
// endpoint caps round the open ends and, where two separate segments share a
|
||||
// coordinate, the coincident spheres fill the miter so the turn looks welded.
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const joint = new Mesh(new SphereGeometry(copperR, RADIAL_SEGMENTS, 10), copperMat)
|
||||
joint.name = `lineset-copper-joint-${i}`
|
||||
joint.position.copy(points[i] as Vector3)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { type LinesetConnectPlan, planLinesetConnect } from './connect'
|
||||
export { linesetDefinition } from './definition'
|
||||
export { buildLinesetGeometry } from './geometry'
|
||||
export { LinesetNode } from './schema'
|
||||
|
||||
@@ -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<AnyNode>)
|
||||
// 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<AnyNode> },
|
||||
...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()
|
||||
|
||||
@@ -1,282 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type LinesetNode,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
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 { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
|
||||
import { createRefrigerantLineSelectionAffordance } from '../shared/refrigerant-line-selection'
|
||||
|
||||
const HANDLE_RADIUS = 0.08
|
||||
const PORT_SNAP_RADIUS_M = 0.4
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
/**
|
||||
* Selection-time editing for committed lineset runs: one draggable handle
|
||||
* per path point. Mirrors the duct-segment path-handle system, but dragged
|
||||
* run endpoints snap onto refrigerant ports only.
|
||||
*
|
||||
* Handles are PORTALED into the lineset's registered scene group so they
|
||||
* share its exact frame. Drag raycasts run in world space and convert hits
|
||||
* back into the group's local frame before writing the path.
|
||||
*/
|
||||
const LinesetSelectionAffordance = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const lineset = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
||||
return node?.type === 'lineset' ? (node as LinesetNode) : null
|
||||
})
|
||||
|
||||
const linesetId = lineset?.id ?? null
|
||||
const [target, setTarget] = useState<Object3D | null>(null)
|
||||
useEffect(() => {
|
||||
if (!linesetId) {
|
||||
setTarget(null)
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(linesetId as AnyNodeId) ?? null
|
||||
setTarget((cur) => (cur === next ? cur : next))
|
||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
resolve()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [linesetId])
|
||||
|
||||
if (!lineset || !target) return null
|
||||
return createPortal(<LinesetPointHandles lineset={lineset} target={target} />, target, undefined)
|
||||
}
|
||||
|
||||
const LinesetPointHandles = ({ lineset, target }: { lineset: LinesetNode; target: Object3D }) => {
|
||||
const { camera, gl } = useThree()
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
||||
const dragRef = useRef<{
|
||||
index: number
|
||||
initialPath: Point[]
|
||||
current: Point
|
||||
cleanup: () => void
|
||||
} | null>(null)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const projectOntoAxis = (
|
||||
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
|
||||
}
|
||||
|
||||
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
|
||||
const toLocal = (world: Vector3): Point => {
|
||||
const local = target.worldToLocal(world.clone())
|
||||
return [local.x, local.y, local.z]
|
||||
}
|
||||
|
||||
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const initialPath = lineset.path.map((p) => [...p] as Point)
|
||||
const startPoint = initialPath[index]!
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
document.body.style.cursor = 'grabbing'
|
||||
setDraggingIndex(index)
|
||||
|
||||
const isEndpoint = index === 0 || index === initialPath.length - 1
|
||||
|
||||
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()
|
||||
const anchorWorldStart = toWorld(startPoint)
|
||||
const axisWorld = toWorld([
|
||||
startPoint[0] + axisLocal.x,
|
||||
startPoint[1] + axisLocal.y,
|
||||
startPoint[2] + axisLocal.z,
|
||||
])
|
||||
.sub(anchorWorldStart)
|
||||
.normalize()
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
const current = drag.current
|
||||
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
||||
let next: Point | null = null
|
||||
if (event.altKey) {
|
||||
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) {
|
||||
const port = findNearestPortXZ(
|
||||
[local[0], current[1], local[2]],
|
||||
collectScenePorts({ excludeNodeId: lineset.id, systems: REFRIGERANT_PORT_SYSTEMS }),
|
||||
PORT_SNAP_RADIUS_M,
|
||||
)
|
||||
if (port) next = [port.position[0], port.position[1], port.position[2]]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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
|
||||
drag.current = next
|
||||
const path = lineset.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
|
||||
useScene.getState().updateNode(lineset.id, { path })
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
drag.cleanup()
|
||||
dragRef.current = null
|
||||
setDraggingIndex(null)
|
||||
const finalPath = drag.initialPath.map((p, i) =>
|
||||
i === drag.index ? drag.current : p,
|
||||
) as Point[]
|
||||
useScene.getState().updateNode(lineset.id, { path: drag.initialPath })
|
||||
resumeSceneHistory(useScene)
|
||||
const moved = finalPath[drag.index]!.some(
|
||||
(v, axis) => v !== drag.initialPath[drag.index]![axis],
|
||||
)
|
||||
if (moved) useScene.getState().updateNode(lineset.id, { path: finalPath })
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
dragRef.current = { index, initialPath, current: startPoint, cleanup }
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
{lineset.path.map((p, i) => {
|
||||
const active = draggingIndex === i
|
||||
const hovered = hoverIndex === i
|
||||
return (
|
||||
<mesh
|
||||
key={`lineset-handle-${i}`}
|
||||
layers={EDITOR_LAYER}
|
||||
onPointerDown={onHandleDown(i)}
|
||||
onPointerEnter={(e) => {
|
||||
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}
|
||||
>
|
||||
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
|
||||
<meshBasicMaterial
|
||||
color={active || hovered ? '#a5b4fc' : '#818cf8'}
|
||||
depthTest={false}
|
||||
opacity={active ? 1 : 0.85}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
})}
|
||||
{draggingIndex !== null &&
|
||||
lineset.path[draggingIndex] &&
|
||||
(() => {
|
||||
const point = lineset.path[draggingIndex]!
|
||||
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
|
||||
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
|
||||
const axes = ['x', 'y', 'z'] as const
|
||||
const primary = axes.reduce((best, axis, i) =>
|
||||
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
|
||||
)
|
||||
return (
|
||||
<Html
|
||||
center
|
||||
position={[point[0], point[1] + 0.35, point[2]]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<DimensionPill
|
||||
parts={axes.map((axis, i) => ({
|
||||
key: axis,
|
||||
prefix: axis.toUpperCase(),
|
||||
value: deltas[i]!,
|
||||
signed: true,
|
||||
}))}
|
||||
primary={primary}
|
||||
unit={unit}
|
||||
/>
|
||||
</Html>
|
||||
)
|
||||
})()}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinesetSelectionAffordance
|
||||
export default createRefrigerantLineSelectionAffordance('lineset')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, emitter, type GridEvent, LinesetNode, useScene } from '@pascal-app/core'
|
||||
import { emitter, type GridEvent, LinesetNode, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
DimensionPill,
|
||||
@@ -16,18 +16,18 @@ import { type Group, Vector3 } from 'three'
|
||||
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
||||
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
|
||||
import { planLinesetConnect } from './connect'
|
||||
import { linesetDefinition } from './definition'
|
||||
|
||||
/**
|
||||
* One-segment-at-a-time placement tool for refrigerant linesets — the
|
||||
* refrigerant-loop sibling of the duct-segment tool.
|
||||
* Continuous placement tool for refrigerant linesets — the refrigerant-loop
|
||||
* sibling of the duct-segment tool.
|
||||
*
|
||||
* Mouse-driven model:
|
||||
* - **First click** anchors the run start. Within range of a refrigerant
|
||||
* service port (a condenser / coil valve, or another lineset's end) it
|
||||
* snaps onto the port so a run mates flush.
|
||||
* - **Second click** commits a two-point lineset and re-arms the tool.
|
||||
* - **Second click** commits a two-point lineset and keeps its far end
|
||||
* anchored, so the next click continues the run like wall / duct drafting.
|
||||
* - The in-flight end is angle-locked to the nearest 45° step in XZ from
|
||||
* the start; Y stays at the start's height. Hold **Shift** to release.
|
||||
* - Hold **Alt** → vertical mode. XZ locks to the start; vertical mouse
|
||||
@@ -103,32 +103,18 @@ const LinesetTool = () => {
|
||||
Math.abs(start[2] - end[2]) < 1e-4
|
||||
if (sameSpot) return
|
||||
|
||||
// Fold into any existing run that shares this segment's endpoint, so
|
||||
// two runs meeting at a coordinate become one mitered path instead of
|
||||
// overlapping nodes. Only same-level runs are candidates — lineset
|
||||
// paths are level-local.
|
||||
const scene = useScene.getState()
|
||||
const existing = Object.values(scene.nodes).filter(
|
||||
(n): n is LinesetNode =>
|
||||
n?.type === 'lineset' && (n.parentId as AnyNodeId | null) === activeLevelId,
|
||||
)
|
||||
const plan = planLinesetConnect(existing, start, end)
|
||||
|
||||
if (plan.kind === 'create') {
|
||||
const lineset = LinesetNode.parse({
|
||||
...linesetDefinition.defaults(),
|
||||
name: 'Lineset',
|
||||
path: plan.path,
|
||||
})
|
||||
scene.createNode(lineset, activeLevelId)
|
||||
} else if (plan.kind === 'extend') {
|
||||
scene.updateNode(plan.id, { path: plan.path })
|
||||
} else {
|
||||
scene.updateNode(plan.id, { path: plan.path })
|
||||
scene.deleteNode(plan.deleteId)
|
||||
}
|
||||
// Each drawn segment is its own standalone two-point lineset node — the
|
||||
// refrigerant-loop sibling of duct-segment. Independent nodes mean each
|
||||
// segment selects and deletes on its own, rather than folding into one
|
||||
// mitered polyline run.
|
||||
const lineset = LinesetNode.parse({
|
||||
...linesetDefinition.defaults(),
|
||||
name: 'Lineset',
|
||||
path: [start, end],
|
||||
})
|
||||
useScene.getState().createNode(lineset, activeLevelId)
|
||||
triggerSFX('sfx:item-place')
|
||||
setDraftPoints([])
|
||||
setDraftPoints([end])
|
||||
setSnapTarget(null)
|
||||
altAnchorRef.current = null
|
||||
setAltActive(false)
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { LiquidLineNode } from './schema'
|
||||
|
||||
type Point = [number, number, number]
|
||||
type LiquidLineId = LiquidLineNode['id']
|
||||
|
||||
/** Coincidence tolerance (meters) for folding endpoints into one run. The
|
||||
* draw tool snaps onto an existing run's endpoint exactly, so this only
|
||||
* needs to absorb float drift, not user aim. */
|
||||
const COINCIDENT_EPS_M = 1e-3
|
||||
|
||||
function samePoint(a: Point, b: Point): boolean {
|
||||
return (
|
||||
Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
|
||||
Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
|
||||
Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
|
||||
)
|
||||
}
|
||||
|
||||
/** Which terminal of `line` coincides with `p`, if either. */
|
||||
function matchEnd(line: LiquidLineNode, p: Point): 'start' | 'end' | null {
|
||||
const path = line.path as Point[]
|
||||
if (samePoint(path[0]!, p)) return 'start'
|
||||
if (samePoint(path[path.length - 1]!, p)) return 'end'
|
||||
return null
|
||||
}
|
||||
|
||||
/** First liquid line whose start or end coincides with `p`. */
|
||||
function findConnection(
|
||||
existing: LiquidLineNode[],
|
||||
p: Point,
|
||||
): { line: LiquidLineNode; side: 'start' | 'end' } | null {
|
||||
for (const line of existing) {
|
||||
if (line.path.length < 2) continue
|
||||
const side = matchEnd(line, p)
|
||||
if (side) return { line, side }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Path re-ordered so the connecting terminal is its LAST point. */
|
||||
function endLast(path: Point[], side: 'start' | 'end'): Point[] {
|
||||
return side === 'end' ? path : [...path].reverse()
|
||||
}
|
||||
|
||||
/** Path re-ordered so the connecting terminal is its FIRST point. */
|
||||
function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
|
||||
return side === 'start' ? path : [...path].reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of committing a new `start`→`end` segment against the existing
|
||||
* liquid-line runs on the same level:
|
||||
* - `create` — no shared endpoint; place a fresh standalone run.
|
||||
* - `extend` — one end lands on run `id`; grow that run's path so the old
|
||||
* terminal becomes an interior point (the geometry miters it).
|
||||
* - `bridge` — both ends land on two *different* runs; weld them plus the
|
||||
* new segment into one path on `id` and delete the absorbed `deleteId`.
|
||||
*/
|
||||
export type LiquidLineConnectPlan =
|
||||
| { kind: 'create'; path: Point[] }
|
||||
| { kind: 'extend'; id: LiquidLineId; path: Point[] }
|
||||
| { kind: 'bridge'; id: LiquidLineId; path: Point[]; deleteId: LiquidLineId }
|
||||
|
||||
/**
|
||||
* Decide how a freshly drawn `start`→`end` segment folds into existing
|
||||
* liquid-line runs that share an endpoint coordinate. Pure: returns a plan,
|
||||
* the caller mutates the scene. Coords are level-local, so `existing` must be
|
||||
* pre-filtered to the segment's level.
|
||||
*/
|
||||
export function planLiquidLineConnect(
|
||||
existing: LiquidLineNode[],
|
||||
start: Point,
|
||||
end: Point,
|
||||
): LiquidLineConnectPlan {
|
||||
const atStart = findConnection(existing, start)
|
||||
const atEnd = findConnection(existing, end)
|
||||
|
||||
// Both ends meet distinct runs → weld the three into one path.
|
||||
if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
|
||||
const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
|
||||
const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
|
||||
return {
|
||||
kind: 'bridge',
|
||||
id: atStart.line.id,
|
||||
path: [...left, ...right],
|
||||
deleteId: atEnd.line.id,
|
||||
}
|
||||
}
|
||||
if (atStart) {
|
||||
const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
|
||||
return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
|
||||
}
|
||||
if (atEnd) {
|
||||
const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
|
||||
return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
|
||||
}
|
||||
return { kind: 'create', path: [start, end] }
|
||||
}
|
||||
@@ -31,8 +31,12 @@ function buildRun(
|
||||
|
||||
/**
|
||||
* Pure geometry builder for a standalone liquid line: a single thin bare-copper
|
||||
* cylinder following the node path centerline, with joint spheres capping
|
||||
* interior corners so turns read as continuous pipe.
|
||||
* cylinder following the node path centerline.
|
||||
*
|
||||
* Each line is a standalone two-point node (no fitting system), so a sphere caps
|
||||
* BOTH endpoints. On a free end it rounds the cap; where two segments share a
|
||||
* coordinate the coincident spheres fill the miter gap, so the turn reads as
|
||||
* continuous pipe.
|
||||
*
|
||||
* Children are level-local meters; `<ParametricNodeRenderer>` owns the node
|
||||
* transform (identity today — the path is absolute within the level).
|
||||
@@ -55,7 +59,10 @@ export function buildLiquidLineGeometry(node: LiquidLineNode): Group {
|
||||
if (run) group.add(run)
|
||||
}
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
// Spherical caps at every point: interior corners read as continuous pipe,
|
||||
// and endpoint caps round the open ends so two separate segments sharing a
|
||||
// coordinate fill the miter and look welded.
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const joint = new Mesh(new SphereGeometry(radius, RADIAL_SEGMENTS, 10), copperMat)
|
||||
joint.name = `liquid-line-joint-${i}`
|
||||
joint.position.copy(points[i] as Vector3)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { type LiquidLineConnectPlan, planLiquidLineConnect } from './connect'
|
||||
export { liquidLineDefinition } from './definition'
|
||||
export { buildLiquidLineGeometry } from './geometry'
|
||||
export { useLiquidLineToolOptions } from './options'
|
||||
|
||||
@@ -1,282 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type LiquidLineNode,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
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 { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
|
||||
import { createRefrigerantLineSelectionAffordance } from '../shared/refrigerant-line-selection'
|
||||
|
||||
const HANDLE_RADIUS = 0.07
|
||||
const PORT_SNAP_RADIUS_M = 0.4
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
/**
|
||||
* Selection-time editing for committed liquid-line runs: one draggable handle
|
||||
* per path point. Mirrors the lineset path-handle system; dragged run
|
||||
* endpoints snap onto refrigerant ports only.
|
||||
*
|
||||
* Handles are PORTALED into the line's registered scene group so they share
|
||||
* its exact frame. Drag raycasts run in world space and convert hits back into
|
||||
* the group's local frame before writing the path.
|
||||
*/
|
||||
const LiquidLineSelectionAffordance = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const line = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
||||
return node?.type === 'liquid-line' ? (node as LiquidLineNode) : null
|
||||
})
|
||||
|
||||
const lineId = line?.id ?? null
|
||||
const [target, setTarget] = useState<Object3D | null>(null)
|
||||
useEffect(() => {
|
||||
if (!lineId) {
|
||||
setTarget(null)
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(lineId as AnyNodeId) ?? null
|
||||
setTarget((cur) => (cur === next ? cur : next))
|
||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
resolve()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [lineId])
|
||||
|
||||
if (!line || !target) return null
|
||||
return createPortal(<LiquidLinePointHandles line={line} target={target} />, target, undefined)
|
||||
}
|
||||
|
||||
const LiquidLinePointHandles = ({ line, target }: { line: LiquidLineNode; target: Object3D }) => {
|
||||
const { camera, gl } = useThree()
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
||||
const dragRef = useRef<{
|
||||
index: number
|
||||
initialPath: Point[]
|
||||
current: Point
|
||||
cleanup: () => void
|
||||
} | null>(null)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const projectOntoAxis = (
|
||||
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
|
||||
}
|
||||
|
||||
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
|
||||
const toLocal = (world: Vector3): Point => {
|
||||
const local = target.worldToLocal(world.clone())
|
||||
return [local.x, local.y, local.z]
|
||||
}
|
||||
|
||||
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const initialPath = line.path.map((p) => [...p] as Point)
|
||||
const startPoint = initialPath[index]!
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
document.body.style.cursor = 'grabbing'
|
||||
setDraggingIndex(index)
|
||||
|
||||
const isEndpoint = index === 0 || index === initialPath.length - 1
|
||||
|
||||
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()
|
||||
const anchorWorldStart = toWorld(startPoint)
|
||||
const axisWorld = toWorld([
|
||||
startPoint[0] + axisLocal.x,
|
||||
startPoint[1] + axisLocal.y,
|
||||
startPoint[2] + axisLocal.z,
|
||||
])
|
||||
.sub(anchorWorldStart)
|
||||
.normalize()
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
const current = drag.current
|
||||
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
||||
let next: Point | null = null
|
||||
if (event.altKey) {
|
||||
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) {
|
||||
const port = findNearestPortXZ(
|
||||
[local[0], current[1], local[2]],
|
||||
collectScenePorts({ excludeNodeId: line.id, systems: REFRIGERANT_PORT_SYSTEMS }),
|
||||
PORT_SNAP_RADIUS_M,
|
||||
)
|
||||
if (port) next = [port.position[0], port.position[1], port.position[2]]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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
|
||||
drag.current = next
|
||||
const path = line.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
|
||||
useScene.getState().updateNode(line.id, { path })
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
drag.cleanup()
|
||||
dragRef.current = null
|
||||
setDraggingIndex(null)
|
||||
const finalPath = drag.initialPath.map((p, i) =>
|
||||
i === drag.index ? drag.current : p,
|
||||
) as Point[]
|
||||
useScene.getState().updateNode(line.id, { path: drag.initialPath })
|
||||
resumeSceneHistory(useScene)
|
||||
const moved = finalPath[drag.index]!.some(
|
||||
(v, axis) => v !== drag.initialPath[drag.index]![axis],
|
||||
)
|
||||
if (moved) useScene.getState().updateNode(line.id, { path: finalPath })
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
dragRef.current = { index, initialPath, current: startPoint, cleanup }
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
{line.path.map((p, i) => {
|
||||
const active = draggingIndex === i
|
||||
const hovered = hoverIndex === i
|
||||
return (
|
||||
<mesh
|
||||
key={`liquid-line-handle-${i}`}
|
||||
layers={EDITOR_LAYER}
|
||||
onPointerDown={onHandleDown(i)}
|
||||
onPointerEnter={(e) => {
|
||||
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}
|
||||
>
|
||||
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
|
||||
<meshBasicMaterial
|
||||
color={active || hovered ? '#a5b4fc' : '#818cf8'}
|
||||
depthTest={false}
|
||||
opacity={active ? 1 : 0.85}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
})}
|
||||
{draggingIndex !== null &&
|
||||
line.path[draggingIndex] &&
|
||||
(() => {
|
||||
const point = line.path[draggingIndex]!
|
||||
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
|
||||
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
|
||||
const axes = ['x', 'y', 'z'] as const
|
||||
const primary = axes.reduce((best, axis, i) =>
|
||||
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
|
||||
)
|
||||
return (
|
||||
<Html
|
||||
center
|
||||
position={[point[0], point[1] + 0.35, point[2]]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<DimensionPill
|
||||
parts={axes.map((axis, i) => ({
|
||||
key: axis,
|
||||
prefix: axis.toUpperCase(),
|
||||
value: deltas[i]!,
|
||||
signed: true,
|
||||
}))}
|
||||
primary={primary}
|
||||
unit={unit}
|
||||
/>
|
||||
</Html>
|
||||
)
|
||||
})()}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default LiquidLineSelectionAffordance
|
||||
export default createRefrigerantLineSelectionAffordance('liquid-line')
|
||||
|
||||
@@ -24,17 +24,17 @@ import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
||||
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||
import { offsetPathHorizontal } from '../shared/path-offset'
|
||||
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
|
||||
import { planLiquidLineConnect } from './connect'
|
||||
import { liquidLineDefinition } from './definition'
|
||||
import { useLiquidLineToolOptions } from './options'
|
||||
|
||||
/**
|
||||
* One-segment-at-a-time placement tool for standalone liquid lines — the same
|
||||
* draw model as the lineset tool (the line it used to be a rail of):
|
||||
* Continuous placement tool for standalone liquid lines — the same draw model
|
||||
* as the lineset tool (the line it used to be a rail of):
|
||||
* - **First click** anchors the run start; within range of a refrigerant
|
||||
* service port it snaps onto it so a run mates flush.
|
||||
* - **Second click** commits a two-point line and re-arms; the in-flight end
|
||||
* is angle-locked to 45° (Shift frees it), Alt drags it vertical.
|
||||
* - **Second click** commits a two-point line and keeps its far end anchored;
|
||||
* the in-flight end is angle-locked to 45° (Shift frees it), Alt drags it
|
||||
* vertical.
|
||||
*
|
||||
* **Follow mode** (toggled by the MEP panel's Follow button or the `F` key):
|
||||
* instead of free-drawing, hover an existing lineset and click — a liquid line
|
||||
@@ -117,46 +117,138 @@ function traceOffsetMeters(lineset: LinesetNode): number {
|
||||
return suctionR + jacket + FOLLOW_GAP_M + GHOST_RADIUS_M
|
||||
}
|
||||
|
||||
type FollowTarget = { lineset: LinesetNode; sign: number }
|
||||
/** Coincidence tolerance (meters) for treating two endpoints as the same joint
|
||||
* when chaining linesets — the draw tool snaps endpoints exactly, so this only
|
||||
* needs to absorb float drift. */
|
||||
const JOINT_EPS_M = 1e-3
|
||||
|
||||
function samePt(a: Vec3, b: Vec3): boolean {
|
||||
return (
|
||||
Math.abs(a[0] - b[0]) < JOINT_EPS_M &&
|
||||
Math.abs(a[1] - b[1]) < JOINT_EPS_M &&
|
||||
Math.abs(a[2] - b[2]) < JOINT_EPS_M
|
||||
)
|
||||
}
|
||||
|
||||
/** Quantized coordinate key so endpoints sharing a joint hash together. */
|
||||
function jointKey(p: Vec3): string {
|
||||
return `${Math.round(p[0] / JOINT_EPS_M)},${Math.round(p[1] / JOINT_EPS_M)},${Math.round(
|
||||
p[2] / JOINT_EPS_M,
|
||||
)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest lineset whose path passes within `FOLLOW_PICK_RADIUS_M` of the
|
||||
* cursor, plus which side of it the cursor is on (`sign`, matching
|
||||
* `offsetPathHorizontal`'s side convention). Restricted to the active level.
|
||||
* Whole-run trace target: the assembled centerline of every lineset chained to
|
||||
* the hovered one (each lineset is its own two-point node now), which side the
|
||||
* cursor is on (`sign`, matching `offsetPathHorizontal`'s convention), and a
|
||||
* representative lineset for the offset distance.
|
||||
*/
|
||||
type FollowTarget = { path: Vec3[]; sign: number; lineset: LinesetNode }
|
||||
|
||||
/**
|
||||
* Walk the chain of linesets joined end-to-end at shared joint coordinates,
|
||||
* starting from `start`, into one continuous centerline. Follows a joint only
|
||||
* when it has a single unvisited continuation (degree-2) — a branch / junction
|
||||
* (degree ≥ 3) ends the run so the trace stays a simple path.
|
||||
*/
|
||||
function assembleRun(start: LinesetNode, linesets: LinesetNode[]): Vec3[] {
|
||||
const byJoint = new Map<string, LinesetNode[]>()
|
||||
for (const ls of linesets) {
|
||||
const a = ls.path[0] as Vec3
|
||||
const b = ls.path[ls.path.length - 1] as Vec3
|
||||
for (const key of [jointKey(a), jointKey(b)]) {
|
||||
const arr = byJoint.get(key)
|
||||
if (arr) arr.push(ls)
|
||||
else byJoint.set(key, [ls])
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set<string>([start.id])
|
||||
let points: Vec3[] = (start.path as Vec3[]).map((p) => [...p] as Vec3)
|
||||
|
||||
// Grow the run one lineset at a time off the chosen terminal, until a joint
|
||||
// has no unique continuation. `atEnd` extends after the last point; otherwise
|
||||
// before the first.
|
||||
const grow = (atEnd: boolean) => {
|
||||
for (;;) {
|
||||
const terminal = atEnd ? points[points.length - 1]! : points[0]!
|
||||
const next = (byJoint.get(jointKey(terminal)) ?? []).filter((ls) => !visited.has(ls.id))
|
||||
if (next.length !== 1) break
|
||||
const node = next[0]!
|
||||
visited.add(node.id)
|
||||
const np = (node.path as Vec3[]).map((p) => [...p] as Vec3)
|
||||
if (atEnd) {
|
||||
if (samePt(np[np.length - 1]!, terminal)) np.reverse() // np must start at terminal
|
||||
points = [...points, ...np.slice(1)]
|
||||
} else {
|
||||
if (samePt(np[0]!, terminal)) np.reverse() // np must end at terminal
|
||||
points = [...np.slice(0, np.length - 1), ...points]
|
||||
}
|
||||
}
|
||||
}
|
||||
grow(true)
|
||||
grow(false)
|
||||
return points
|
||||
}
|
||||
|
||||
/** Cursor side relative to the assembled run's nearest segment, as the offset
|
||||
* sign for `offsetPathHorizontal`. */
|
||||
function sideSign(path: Vec3[], point: Vec3): number {
|
||||
let bestD = Number.POSITIVE_INFINITY
|
||||
let bi = 0
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const d = distToSegmentXZ(point, path[i]!, path[i + 1]!)
|
||||
if (d < bestD) {
|
||||
bestD = d
|
||||
bi = i
|
||||
}
|
||||
}
|
||||
const a = path[bi]!
|
||||
const b = path[bi + 1]!
|
||||
// Side vector = normalize(heading_xz) × UP = (-hz, 0, hx).
|
||||
const hx = b[0] - a[0]
|
||||
const hz = b[2] - a[2]
|
||||
const hlen = Math.hypot(hx, hz)
|
||||
const sx = hlen > 1e-9 ? -hz / hlen : 0
|
||||
const sz = hlen > 1e-9 ? hx / hlen : 0
|
||||
return (point[0] - a[0]) * sx + (point[2] - a[2]) * sz >= 0 ? 1 : -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest lineset within `FOLLOW_PICK_RADIUS_M` of the cursor, expanded into
|
||||
* the whole connected run it belongs to. Restricted to the active level.
|
||||
*/
|
||||
function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null {
|
||||
const scene = useScene.getState()
|
||||
let best: FollowTarget | null = null
|
||||
let bestD = FOLLOW_PICK_RADIUS_M
|
||||
const linesets: LinesetNode[] = []
|
||||
for (const n of Object.values(scene.nodes)) {
|
||||
if (!n || n.type !== 'lineset') continue
|
||||
if ((n.parentId as AnyNodeId | null) !== levelId) continue
|
||||
const ls = n as LinesetNode
|
||||
if (ls.path.length < 2) continue
|
||||
if (ls.path.length >= 2) linesets.push(ls)
|
||||
}
|
||||
|
||||
let hovered: LinesetNode | null = null
|
||||
let bestD = FOLLOW_PICK_RADIUS_M
|
||||
for (const ls of linesets) {
|
||||
for (let i = 0; i < ls.path.length - 1; i++) {
|
||||
const a = ls.path[i] as Vec3
|
||||
const b = ls.path[i + 1] as Vec3
|
||||
const d = distToSegmentXZ(point, a, b)
|
||||
const d = distToSegmentXZ(point, ls.path[i] as Vec3, ls.path[i + 1] as Vec3)
|
||||
if (d >= bestD) continue
|
||||
bestD = d
|
||||
// Side vector = normalize(heading_xz) × UP = (-hz, 0, hx); sign is which
|
||||
// side of the segment the cursor sits on.
|
||||
const hx = b[0] - a[0]
|
||||
const hz = b[2] - a[2]
|
||||
const hlen = Math.hypot(hx, hz)
|
||||
const sx = hlen > 1e-9 ? -hz / hlen : 0
|
||||
const sz = hlen > 1e-9 ? hx / hlen : 0
|
||||
const dot = (point[0] - a[0]) * sx + (point[2] - a[2]) * sz
|
||||
best = { lineset: ls, sign: dot >= 0 ? 1 : -1 }
|
||||
hovered = ls
|
||||
}
|
||||
}
|
||||
return best
|
||||
if (!hovered) return null
|
||||
|
||||
const path = assembleRun(hovered, linesets)
|
||||
if (path.length < 2) return null
|
||||
return { path, sign: sideSign(path, point), lineset: hovered }
|
||||
}
|
||||
|
||||
/** The offset path a follow-target would trace, or null if degenerate. */
|
||||
/** The offset centerline a follow-target would trace, or null if degenerate. */
|
||||
function tracePath(target: FollowTarget): Vec3[] | null {
|
||||
const offset = target.sign * traceOffsetMeters(target.lineset)
|
||||
const traced = offsetPathHorizontal(target.lineset.path as Vec3[], offset)
|
||||
const traced = offsetPathHorizontal(target.path, offset)
|
||||
return traced.length >= 2 ? traced : null
|
||||
}
|
||||
|
||||
@@ -199,47 +291,39 @@ const LiquidLineTool = () => {
|
||||
Math.abs(start[2] - end[2]) < 1e-4
|
||||
if (sameSpot) return
|
||||
|
||||
// Fold into any existing run that shares this segment's endpoint, so two
|
||||
// runs meeting at a coordinate become one mitered path instead of
|
||||
// overlapping nodes. Only same-level runs are candidates.
|
||||
const scene = useScene.getState()
|
||||
const existing = Object.values(scene.nodes).filter(
|
||||
(n): n is LiquidLineNode =>
|
||||
n?.type === 'liquid-line' && (n.parentId as AnyNodeId | null) === activeLevelId,
|
||||
)
|
||||
const plan = planLiquidLineConnect(existing, start, end)
|
||||
|
||||
if (plan.kind === 'create') {
|
||||
const line = LiquidLineNode.parse({
|
||||
...liquidLineDefinition.defaults(),
|
||||
name: 'Liquid Line',
|
||||
path: plan.path,
|
||||
})
|
||||
scene.createNode(line, activeLevelId)
|
||||
} else if (plan.kind === 'extend') {
|
||||
scene.updateNode(plan.id, { path: plan.path })
|
||||
} else {
|
||||
scene.updateNode(plan.id, { path: plan.path })
|
||||
scene.deleteNode(plan.deleteId)
|
||||
}
|
||||
// Each drawn segment is its own standalone two-point liquid-line node.
|
||||
// Independent nodes mean each segment selects and deletes on its own,
|
||||
// rather than folding into one mitered polyline run.
|
||||
const line = LiquidLineNode.parse({
|
||||
...liquidLineDefinition.defaults(),
|
||||
name: 'Liquid Line',
|
||||
path: [start, end],
|
||||
})
|
||||
useScene.getState().createNode(line, activeLevelId)
|
||||
triggerSFX('sfx:item-place')
|
||||
setDraftPoints([])
|
||||
setDraftPoints([end])
|
||||
setSnapTarget(null)
|
||||
altAnchorRef.current = null
|
||||
setAltActive(false)
|
||||
}
|
||||
|
||||
// Lay a liquid line beside a lineset, tracing its whole path at the offset.
|
||||
// Lay liquid lines beside the whole connected lineset run, tracing its
|
||||
// assembled centerline at the offset. One two-point node per segment so the
|
||||
// result stays per-segment selectable, matching free-drawn liquid lines.
|
||||
const commitTrace = (target: FollowTarget) => {
|
||||
const traced = tracePath(target)
|
||||
if (!traced) return
|
||||
const scene = useScene.getState()
|
||||
const line = LiquidLineNode.parse({
|
||||
...liquidLineDefinition.defaults(),
|
||||
name: 'Liquid Line',
|
||||
path: traced,
|
||||
})
|
||||
scene.createNode(line, activeLevelId)
|
||||
const defaults = liquidLineDefinition.defaults()
|
||||
const create = []
|
||||
for (let i = 0; i < traced.length - 1; i++) {
|
||||
const a = traced[i]!
|
||||
const b = traced[i + 1]!
|
||||
if (samePt(a, b)) continue
|
||||
const node = LiquidLineNode.parse({ ...defaults, name: 'Liquid Line', path: [a, b] })
|
||||
create.push({ node, parentId: activeLevelId })
|
||||
}
|
||||
if (create.length === 0) return
|
||||
useScene.getState().applyNodeChanges({ create })
|
||||
triggerSFX('sfx:item-place')
|
||||
setTraceGhost(null)
|
||||
followTargetRef.current = null
|
||||
@@ -471,7 +555,7 @@ const LiquidLineTool = () => {
|
||||
}}
|
||||
>
|
||||
{followTargetRef.current
|
||||
? 'Click to trace this lineset'
|
||||
? 'Click to trace this lineset run'
|
||||
: 'Follow: hover a lineset'}
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
@@ -78,6 +78,7 @@ export const pipeFittingDefinition: NodeDefinition<typeof PipeFittingNode> = {
|
||||
// editor's SelectionAffordanceManager rather than `def.system`.
|
||||
affordanceTools: {
|
||||
selection: () => import('./selection'),
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
|
||||
tool: () => import('./tool'),
|
||||
|
||||
@@ -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<string, unknown>).isNew === true
|
||||
|
||||
const [cursorPos, setCursorPos] = useState<Vec3>(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<string, unknown>),
|
||||
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<AnyNode> },
|
||||
...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 (
|
||||
<group>
|
||||
<primitive object={ghost} position={cursorPos} rotation={rotation} />
|
||||
<DragBoundingBox
|
||||
centerY={bounds.offset[1]}
|
||||
nodeId={node.id}
|
||||
position={[cursorPos[0] + bounds.offset[0], cursorPos[1], cursorPos[2] + bounds.offset[2]]}
|
||||
size={bounds.size}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default MovePipeFittingTool
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, type AnyNodeId, PipeFittingNode, PipeSegmentNode } from '@pascal-app/core'
|
||||
import { pipeFittingParametrics } from './parametrics'
|
||||
import { getPipeFittingPorts } from './ports'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
function pipeElbow() {
|
||||
return PipeFittingNode.parse({
|
||||
id: 'pipe-fitting_elbow' as AnyNodeId,
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'DWV bend',
|
||||
fittingType: 'elbow',
|
||||
angle: 90,
|
||||
diameter: 3,
|
||||
diameter2: 3,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
})
|
||||
}
|
||||
|
||||
function pipe(id: string, path: Point[]) {
|
||||
return PipeSegmentNode.parse({
|
||||
id: id as AnyNodeId,
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'DWV pipe',
|
||||
path,
|
||||
diameter: 3,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
})
|
||||
}
|
||||
|
||||
function add(point: readonly number[], dir: readonly number[], length: number): Point {
|
||||
return [point[0]! + dir[0]! * length, point[1]! + dir[1]! * length, point[2]! + dir[2]! * length]
|
||||
}
|
||||
|
||||
describe('pipeFittingParametrics', () => {
|
||||
test('deleting an elbow re-extends mated pipe ends back onto the junction', () => {
|
||||
const fitting = pipeElbow()
|
||||
const inlet = getPipeFittingPorts(fitting).find((p) => p.id === 'inlet')!
|
||||
const outlet = getPipeFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const inletRun = pipe('pipe-segment_inlet', [
|
||||
add(inlet.position, inlet.direction, 3),
|
||||
[...inlet.position] as Point,
|
||||
])
|
||||
const outletRun = pipe('pipe-segment_outlet', [
|
||||
[...outlet.position] as Point,
|
||||
add(outlet.position, outlet.direction, 3),
|
||||
])
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
[outletRun.id]: outletRun as AnyNode,
|
||||
}
|
||||
|
||||
const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
const inletUpdate = updates.find((u) => u.id === inletRun.id)
|
||||
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
||||
|
||||
expect((inletUpdate?.data as Partial<PipeSegmentNode>).path?.[1]).toEqual([...fitting.position])
|
||||
expect((outletUpdate?.data as Partial<PipeSegmentNode>).path?.[0]).toEqual([
|
||||
...fitting.position,
|
||||
])
|
||||
})
|
||||
|
||||
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
|
||||
const fitting = pipeElbow()
|
||||
const inlet = getPipeFittingPorts(fitting).find((p) => p.id === 'inlet')!
|
||||
const inletRun = pipe('pipe-segment_inlet', [
|
||||
add(inlet.position, inlet.direction, 3),
|
||||
add(inlet.position, [0, 0, 1], 0.04),
|
||||
])
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
}
|
||||
|
||||
const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
|
||||
expect((updates[0]?.data as Partial<PipeSegmentNode>).path?.[1]).toEqual([...fitting.position])
|
||||
})
|
||||
|
||||
test('deleting a branch fitting leaves mated pipe ends untouched', () => {
|
||||
const wye = PipeFittingNode.parse({ ...pipeElbow(), fittingType: 'wye' })
|
||||
const inlet = getPipeFittingPorts(wye).find((p) => p.id === 'inlet')!
|
||||
const inletRun = pipe('pipe-segment_inlet', [
|
||||
add(inlet.position, inlet.direction, 3),
|
||||
[...inlet.position] as Point,
|
||||
])
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[wye.id]: wye as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
}
|
||||
|
||||
expect(pipeFittingParametrics.onDelete?.(wye, nodes) ?? []).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,62 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { AnyNode, AnyNodeId, ParametricDescriptor, PipeSegmentNode } from '@pascal-app/core'
|
||||
import { getPipeFittingPorts } from './ports'
|
||||
import type { PipeFittingNode } from './schema'
|
||||
|
||||
/** A pipe endpoint sitting this close to a fitting hub counts as mated. */
|
||||
const MATE_TOL_M = 0.05
|
||||
|
||||
type Point = [number, number, number]
|
||||
type PipeMate = { pipe: PipeSegmentNode; endIndex: number }
|
||||
|
||||
function matedPipes(
|
||||
fitting: PipeFittingNode,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): Map<string, PipeMate> {
|
||||
const mates = new Map<string, PipeMate>()
|
||||
const ports = getPipeFittingPorts(fitting)
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'pipe-segment') continue
|
||||
const pipe = node as PipeSegmentNode
|
||||
for (const endIndex of [0, pipe.path.length - 1]) {
|
||||
const p = pipe.path[endIndex]
|
||||
if (!p) continue
|
||||
for (const port of ports) {
|
||||
if (mates.has(port.id)) continue
|
||||
const dx = p[0] - port.position[0]
|
||||
const dy = p[1] - port.position[1]
|
||||
const dz = p[2] - port.position[2]
|
||||
if (dx * dx + dy * dy + dz * dz <= MATE_TOL_M * MATE_TOL_M) {
|
||||
mates.set(port.id, { pipe, endIndex })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return mates
|
||||
}
|
||||
|
||||
export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
|
||||
// Deleting an auto-inserted DWV bend restores the corner it replaced.
|
||||
// The connected pipe endpoints were pulled back onto the bend collars;
|
||||
// send those endpoints back to the junction so the L-shape regains its
|
||||
// original length.
|
||||
onDelete: (fitting, nodes) => {
|
||||
if (fitting.fittingType !== 'elbow') return []
|
||||
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
||||
for (const mate of matedPipes(fitting, nodes).values()) {
|
||||
const end = mate.pipe.path[mate.endIndex]
|
||||
if (!end) continue
|
||||
const target = fitting.position
|
||||
const dx = end[0] - target[0]
|
||||
const dy = end[1] - target[1]
|
||||
const dz = end[2] - target[2]
|
||||
if (dx * dx + dy * dy + dz * dz < 1e-12) continue
|
||||
const path = mate.pipe.path.map((p) => [...p] as Point)
|
||||
path[mate.endIndex] = [...target]
|
||||
updates.push({ id: mate.pipe.id, data: { path } as Partial<PipeSegmentNode> })
|
||||
}
|
||||
return updates
|
||||
},
|
||||
|
||||
groups: [
|
||||
{
|
||||
label: 'Fitting',
|
||||
@@ -16,7 +71,7 @@ export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
|
||||
key: 'angle',
|
||||
kind: 'number',
|
||||
unit: '°',
|
||||
min: 15,
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 7.5,
|
||||
visibleIf: (n) => n.fittingType === 'elbow',
|
||||
|
||||
@@ -1,27 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
analyzePortConnectivity,
|
||||
type Cursor,
|
||||
type PipeFittingNode,
|
||||
type PortConnectivity,
|
||||
pauseSceneHistory,
|
||||
resolveConnectivityUpdates,
|
||||
resumeSceneHistory,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ARROW_COLOR,
|
||||
EDITOR_LAYER,
|
||||
swallowNextClick,
|
||||
triggerSFX,
|
||||
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, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
LineSegments,
|
||||
type Object3D,
|
||||
OrthographicCamera,
|
||||
Plane,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
SphereGeometry,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
AXIS_VECTORS,
|
||||
cycleRotationAxis,
|
||||
ROTATE_STEP_RAD,
|
||||
type RotationAxis,
|
||||
} from '../shared/fitting-rotation'
|
||||
import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles'
|
||||
import { pipeFittingLegLength } from './ports'
|
||||
|
||||
type Point = [number, number, number]
|
||||
type FittingTransform = { position?: Point; rotation?: Point }
|
||||
type PipeDimension = 'diameter' | 'diameter2'
|
||||
|
||||
const ARROW_GAP = 0.34
|
||||
const RESIZE_HANDLE_GAP = 0.3
|
||||
const RESIZE_STEP_IN = 0.25
|
||||
const RESIZE_GUIDE_DASH = 0.07
|
||||
const RESIZE_GUIDE_GAP = 0.045
|
||||
const RESIZE_SPHERE_RADIUS = 0.065
|
||||
const RESIZE_HIT_RADIUS = 0.13
|
||||
const INCHES_TO_METERS = 0.0254
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function fittingExtentM(node: PipeFittingNode): number {
|
||||
return Math.max(pipeFittingLegLength(node.diameter), pipeFittingLegLength(node.diameter2))
|
||||
}
|
||||
|
||||
function fittingParameterPatch(node: PipeFittingNode): Partial<PipeFittingNode> {
|
||||
return {
|
||||
fittingType: node.fittingType,
|
||||
angle: node.angle,
|
||||
diameter: node.diameter,
|
||||
diameter2: node.diameter2,
|
||||
pipeMaterial: node.pipeMaterial,
|
||||
system: node.system,
|
||||
}
|
||||
}
|
||||
|
||||
function preserveFittingParameters(
|
||||
node: PipeFittingNode,
|
||||
data: Partial<PipeFittingNode>,
|
||||
): Partial<AnyNode> {
|
||||
return { ...fittingParameterPatch(node), ...data } as Partial<AnyNode>
|
||||
}
|
||||
|
||||
function dimensionPatch(
|
||||
fitting: PipeFittingNode,
|
||||
dimension: PipeDimension,
|
||||
value: number,
|
||||
): Partial<PipeFittingNode> {
|
||||
if (dimension === 'diameter' && fitting.fittingType === 'elbow') {
|
||||
return { diameter: value, diameter2: value }
|
||||
}
|
||||
return { [dimension]: value } as Partial<PipeFittingNode>
|
||||
}
|
||||
|
||||
function closestAxisParameterToRay(
|
||||
axisOrigin: Vector3,
|
||||
axisDirection: Vector3,
|
||||
ray: Raycaster['ray'],
|
||||
) {
|
||||
const originToRay = axisOrigin.clone().sub(ray.origin)
|
||||
const b = axisDirection.dot(ray.direction)
|
||||
const d = axisDirection.dot(originToRay)
|
||||
const e = ray.direction.dot(originToRay)
|
||||
const denominator = 1 - b * b
|
||||
if (Math.abs(denominator) < 1e-6) return -d
|
||||
const axisParameter = (b * e - d) / denominator
|
||||
const rayParameter = e + b * axisParameter
|
||||
return rayParameter < 0 ? -d : axisParameter
|
||||
}
|
||||
|
||||
function DashedResizeGuide({ from, to }: { from: Point; to: Point }) {
|
||||
const line = useMemo(() => {
|
||||
const a = new Vector3(from[0], from[1], from[2])
|
||||
const b = new Vector3(to[0], to[1], to[2])
|
||||
const span = b.clone().sub(a)
|
||||
const length = span.length()
|
||||
const points: number[] = []
|
||||
if (length > 1e-4) {
|
||||
const dir = span.clone().normalize()
|
||||
let t = 0
|
||||
while (t < length) {
|
||||
const start = a.clone().addScaledVector(dir, t)
|
||||
const end = a.clone().addScaledVector(dir, Math.min(t + RESIZE_GUIDE_DASH, length))
|
||||
points.push(start.x, start.y, start.z, end.x, end.y, end.z)
|
||||
t += RESIZE_GUIDE_DASH + RESIZE_GUIDE_GAP
|
||||
}
|
||||
}
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(points), 3))
|
||||
const material = new LineBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
depthWrite: false,
|
||||
})
|
||||
const next = new LineSegments(geometry, material)
|
||||
next.frustumCulled = false
|
||||
next.layers.set(EDITOR_LAYER)
|
||||
next.renderOrder = 1002
|
||||
next.raycast = () => {}
|
||||
return next
|
||||
}, [from, to])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
line.geometry.dispose()
|
||||
;(line.material as LineBasicNodeMaterial).dispose()
|
||||
},
|
||||
[line],
|
||||
)
|
||||
|
||||
return <primitive object={line} />
|
||||
}
|
||||
|
||||
function ResizeSphereHandle({
|
||||
cursor,
|
||||
onPointerDown,
|
||||
position,
|
||||
}: {
|
||||
cursor: Cursor
|
||||
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||
position: Point
|
||||
}) {
|
||||
const { camera } = useThree()
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const sphereGeometry = useMemo(() => new SphereGeometry(RESIZE_SPHERE_RADIUS, 18, 12), [])
|
||||
const hitGeometry = useMemo(() => new SphereGeometry(RESIZE_HIT_RADIUS, 12, 8), [])
|
||||
const sphereMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.92,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const hitMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
sphereMaterial.opacity = hovered ? 1 : 0.92
|
||||
}, [sphereMaterial, hovered])
|
||||
useEffect(
|
||||
() => () => {
|
||||
hitGeometry.dispose()
|
||||
sphereGeometry.dispose()
|
||||
sphereMaterial.dispose()
|
||||
hitMaterial.dispose()
|
||||
},
|
||||
[hitGeometry, hitMaterial, sphereGeometry, sphereMaterial],
|
||||
)
|
||||
|
||||
const consumePress = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.stopPropagation()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
swallowNextClick()
|
||||
onPointerDown(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={position} scale={zoom}>
|
||||
<mesh
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={consumePress}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
setHovered(true)
|
||||
document.body.style.cursor = cursor
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
event.stopPropagation()
|
||||
setHovered(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}}
|
||||
/>
|
||||
<mesh geometry={sphereGeometry} material={sphereMaterial} renderOrder={1004} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-time rotation support for placed pipe fittings — mirrors
|
||||
* the duct-fitting affordance, mounted by the editor's
|
||||
* SelectionAffordanceManager (`def.affordanceTools.selection`). R/T
|
||||
* rotation lives in `def.keyboardActions`; 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 — so this
|
||||
* component renders nothing.
|
||||
*/
|
||||
const PipeFittingSelectionAffordance = () => {
|
||||
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 === 'pipe-fitting'
|
||||
const fitting = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
||||
return node?.type === 'pipe-fitting' ? (node as PipeFittingNode) : null
|
||||
})
|
||||
|
||||
const hasSelectedFitting = !!fitting
|
||||
useEffect(() => {
|
||||
if (!hasSelectedFitting) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -31,13 +263,527 @@ const PipeFittingSelectionAffordance = () => {
|
||||
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
|
||||
const fittingId = fitting?.id ?? null
|
||||
const [target, setTarget] = useState<Object3D | null>(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(<FittingHandles fitting={fitting} />, mount, undefined)
|
||||
}
|
||||
|
||||
const FittingHandles = ({ fitting }: { fitting: PipeFittingNode }) => {
|
||||
const { camera, gl } = useThree()
|
||||
const [frame, setFrame] = useState<Group | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [sideSign, setSideSign] = useState(1)
|
||||
|
||||
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
|
||||
}
|
||||
const sampleAxisParameter = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
axisOrigin: Vector3,
|
||||
axisDirection: Vector3,
|
||||
): number => closestAxisParameterToRay(axisOrigin, axisDirection, makeRay(clientX, clientY))
|
||||
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])
|
||||
const axisToWorld = (origin: Point, axis: Vector3): Vector3 => {
|
||||
const originWorld = toWorld(origin)
|
||||
const tipWorld = frame
|
||||
? frame.localToWorld(new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z))
|
||||
: new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z)
|
||||
return tipWorld.sub(originWorld).normalize()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const connectivityUpdates = (
|
||||
connectivity: PortConnectivity | null,
|
||||
transform: FittingTransform,
|
||||
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
||||
if (!connectivity) return []
|
||||
const preview = { ...(fitting as Record<string, unknown>), ...transform } as AnyNode
|
||||
const nodes = useScene.getState().nodes
|
||||
return resolveConnectivityUpdates(connectivity, preview)
|
||||
.filter((u) => nodes[u.id])
|
||||
.map((u) => {
|
||||
const node = nodes[u.id]
|
||||
if (node?.type !== 'pipe-fitting') return u
|
||||
return {
|
||||
id: u.id,
|
||||
data: preserveFittingParameters(
|
||||
node as PipeFittingNode,
|
||||
u.data as Partial<PipeFittingNode>,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const beginDrag =
|
||||
(
|
||||
cursor: Cursor,
|
||||
makeCompute: (
|
||||
e: ThreeEvent<PointerEvent>,
|
||||
) => (event: PointerEvent) => FittingTransform | null,
|
||||
) =>
|
||||
(e: ThreeEvent<PointerEvent>) => {
|
||||
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<AnyNode> }[] => [
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, t as Partial<PipeFittingNode>),
|
||||
},
|
||||
...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 = () => {
|
||||
swallowNextClick()
|
||||
cleanup()
|
||||
const reverts: { id: AnyNodeId; data: Partial<AnyNode> }[] = (
|
||||
connectivity?.connections ?? []
|
||||
).map((conn) => {
|
||||
if (conn.kind !== 'rigid-node') {
|
||||
return { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }
|
||||
}
|
||||
const node = useScene.getState().nodes[conn.nodeId]
|
||||
return {
|
||||
id: conn.nodeId,
|
||||
data:
|
||||
node?.type === 'pipe-fitting'
|
||||
? preserveFittingParameters(node as PipeFittingNode, {
|
||||
position: conn.startPosition as Point,
|
||||
})
|
||||
: ({ position: conn.startPosition } as Partial<AnyNode>),
|
||||
}
|
||||
})
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, {
|
||||
position: initialPosition,
|
||||
rotation: initialRotation,
|
||||
}),
|
||||
},
|
||||
...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)
|
||||
}
|
||||
|
||||
const moveCompute =
|
||||
(axis: RotationAxis) =>
|
||||
(e: ThreeEvent<PointerEvent>): ((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] = base[axisIndex] + delta
|
||||
return { position: next }
|
||||
}
|
||||
}
|
||||
|
||||
const rotateCompute =
|
||||
(axis: RotationAxis) =>
|
||||
(e: ThreeEvent<PointerEvent>): ((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]),
|
||||
)
|
||||
let lastStep = Number.NaN
|
||||
return (event: PointerEvent): FittingTransform | null => {
|
||||
if (startBearing === null) return null
|
||||
const b = bearing(event.clientX, event.clientY)
|
||||
if (b === null) return null
|
||||
const raw = b - startBearing
|
||||
const delta = event.shiftKey ? raw : Math.round(raw / ROTATE_STEP_RAD) * ROTATE_STEP_RAD
|
||||
if (!event.shiftKey) {
|
||||
const step = Math.round(raw / ROTATE_STEP_RAD)
|
||||
if (step !== lastStep) {
|
||||
lastStep = step
|
||||
triggerSFX('sfx:item-rotate')
|
||||
}
|
||||
}
|
||||
const turn = new Quaternion().setFromAxisAngle(normal, delta)
|
||||
const euler = new Euler().setFromQuaternion(turn.multiply(startQuat))
|
||||
return { rotation: [euler.x, euler.y, euler.z] }
|
||||
}
|
||||
}
|
||||
|
||||
const beginDimensionDrag =
|
||||
(dimension: PipeDimension, axisLocal: Vector3, cursor: Cursor) =>
|
||||
(e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const baseValue = fitting[dimension]
|
||||
const initialPatch = dimensionPatch(fitting, dimension, baseValue)
|
||||
const centerWorld = toWorld(fitting.position as Point)
|
||||
const axisWorld = axisToWorld(fitting.position as Point, axisLocal)
|
||||
const start = sampleAxisParameter(
|
||||
e.nativeEvent.clientX,
|
||||
e.nativeEvent.clientY,
|
||||
centerWorld,
|
||||
axisWorld,
|
||||
)
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
setDragging(true)
|
||||
document.body.style.cursor = cursor
|
||||
let current: Partial<PipeFittingNode> | null = null
|
||||
let lastValue = Number.NaN
|
||||
|
||||
const apply = (patch: Partial<PipeFittingNode>) => {
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, patch),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const rawDeltaM =
|
||||
sampleAxisParameter(event.clientX, event.clientY, centerWorld, axisWorld) - start
|
||||
const deltaIn = (rawDeltaM / INCHES_TO_METERS) * 2
|
||||
const nextRaw = baseValue + deltaIn
|
||||
const nextValue = clamp(event.shiftKey ? nextRaw : snap(nextRaw, RESIZE_STEP_IN), 1.25, 8)
|
||||
if (nextValue === lastValue) return
|
||||
lastValue = nextValue
|
||||
current = dimensionPatch(fitting, dimension, nextValue)
|
||||
if (!event.shiftKey) triggerSFX('sfx:grid-snap')
|
||||
apply(current)
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
swallowNextClick()
|
||||
cleanup()
|
||||
apply(initialPatch)
|
||||
resumeSceneHistory(useScene)
|
||||
if (current) apply(current)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
const extent = useMemo(() => fittingExtentM(fitting), [fitting])
|
||||
const p = fitting.position as Point
|
||||
const base = extent + ARROW_GAP
|
||||
const fittingRotation = useMemo(
|
||||
() => new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
|
||||
[fitting.rotation],
|
||||
)
|
||||
const runDiameterAxis = useMemo(() => {
|
||||
const axis = new Vector3(0, 1, 0).applyEuler(fittingRotation).normalize()
|
||||
return axis.dot(UP) >= 0 ? axis : axis.multiplyScalar(-1)
|
||||
}, [fittingRotation])
|
||||
const baseBranchAxis = useMemo(
|
||||
() => new Vector3(0, 0, 1).applyEuler(fittingRotation).normalize(),
|
||||
[fittingRotation],
|
||||
)
|
||||
const branchAxis = useMemo(
|
||||
() => baseBranchAxis.clone().multiplyScalar(sideSign),
|
||||
[baseBranchAxis, sideSign],
|
||||
)
|
||||
useFrame(() => {
|
||||
if (!frame || fitting.fittingType === 'elbow') return
|
||||
const cameraPosition = camera.getWorldPosition(new Vector3())
|
||||
const cameraLocal = frame.worldToLocal(cameraPosition)
|
||||
const toCamera = cameraLocal.sub(new Vector3(p[0], p[1], p[2]))
|
||||
const nextSign = baseBranchAxis.dot(toCamera) >= 0 ? 1 : -1
|
||||
setSideSign((current) => (current === nextSign ? current : nextSign))
|
||||
})
|
||||
|
||||
const resizeHandleBase = extent + RESIZE_HANDLE_GAP
|
||||
const resizeHandles: {
|
||||
key: PipeDimension
|
||||
axis: Vector3
|
||||
cursor: Cursor
|
||||
guideFrom: Point
|
||||
guideTo: Point
|
||||
position: Point
|
||||
}[] = [
|
||||
{
|
||||
key: 'diameter',
|
||||
axis: runDiameterAxis,
|
||||
cursor: 'ns-resize',
|
||||
guideFrom: [
|
||||
p[0] + runDiameterAxis.x * resizeHandleBase,
|
||||
p[1] + runDiameterAxis.y * resizeHandleBase,
|
||||
p[2] + runDiameterAxis.z * resizeHandleBase,
|
||||
],
|
||||
guideTo: [
|
||||
p[0] + runDiameterAxis.x * Math.max(extent * 0.18, 0.04),
|
||||
p[1] + runDiameterAxis.y * Math.max(extent * 0.18, 0.04),
|
||||
p[2] + runDiameterAxis.z * Math.max(extent * 0.18, 0.04),
|
||||
],
|
||||
position: [
|
||||
p[0] + runDiameterAxis.x * resizeHandleBase,
|
||||
p[1] + runDiameterAxis.y * resizeHandleBase,
|
||||
p[2] + runDiameterAxis.z * resizeHandleBase,
|
||||
],
|
||||
},
|
||||
...(fitting.fittingType === 'elbow'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: 'diameter2' as const,
|
||||
axis: branchAxis,
|
||||
cursor: 'ew-resize' as Cursor,
|
||||
guideFrom: [
|
||||
p[0] + branchAxis.x * resizeHandleBase,
|
||||
p[1] + branchAxis.y * resizeHandleBase,
|
||||
p[2] + branchAxis.z * resizeHandleBase,
|
||||
] as Point,
|
||||
guideTo: [
|
||||
p[0] + branchAxis.x * Math.max(extent * 0.18, 0.04),
|
||||
p[1] + branchAxis.y * Math.max(extent * 0.18, 0.04),
|
||||
p[2] + branchAxis.z * Math.max(extent * 0.18, 0.04),
|
||||
] as Point,
|
||||
position: [
|
||||
p[0] + branchAxis.x * resizeHandleBase,
|
||||
p[1] + branchAxis.y * resizeHandleBase,
|
||||
p[2] + branchAxis.z * resizeHandleBase,
|
||||
] as Point,
|
||||
},
|
||||
]),
|
||||
]
|
||||
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
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])
|
||||
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 <group ref={setFrame} />
|
||||
return (
|
||||
<group ref={setFrame}>
|
||||
<HandleCube active={open} onClick={() => setOpen((o) => !o)} position={p} />
|
||||
{!open &&
|
||||
resizeHandles.map((handle) => (
|
||||
<group key={handle.key}>
|
||||
<DashedResizeGuide from={handle.guideFrom} to={handle.guideTo} />
|
||||
<ResizeSphereHandle
|
||||
cursor={handle.cursor}
|
||||
onPointerDown={beginDimensionDrag(handle.key, handle.axis, handle.cursor)}
|
||||
position={handle.position}
|
||||
/>
|
||||
</group>
|
||||
))}
|
||||
{open && (
|
||||
<>
|
||||
{moveArrows.map((a) => (
|
||||
<MoveChevron
|
||||
cursor={a.cursor}
|
||||
key={a.key}
|
||||
onPointerDown={beginDrag(
|
||||
a.axis === 'y' ? 'ns-resize' : 'grabbing',
|
||||
moveCompute(a.axis),
|
||||
)}
|
||||
position={a.position}
|
||||
rotationY={a.rotationY}
|
||||
vertical={a.vertical}
|
||||
/>
|
||||
))}
|
||||
{rotateArcs.map((arc) => (
|
||||
<RotateArc
|
||||
key={arc.key}
|
||||
onPointerDown={beginDrag('grabbing', rotateCompute(arc.axis))}
|
||||
position={arc.position}
|
||||
rotation={arc.rotation}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipeFittingSelectionAffordance
|
||||
|
||||
@@ -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<AnyNode>)
|
||||
// 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<AnyNode> },
|
||||
...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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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). */
|
||||
@@ -88,6 +93,28 @@ function findNearbyPort(point: [number, number, number]): ScenePort | null {
|
||||
)
|
||||
}
|
||||
|
||||
function pipeEndPort(pipe: PipeSegmentNode, id: 'start' | 'end'): ScenePort | null {
|
||||
if (pipe.path.length < 2) return null
|
||||
const index = id === 'start' ? 0 : pipe.path.length - 1
|
||||
const neighborIndex = id === 'start' ? 1 : pipe.path.length - 2
|
||||
const position = pipe.path[index]!
|
||||
const neighbor = pipe.path[neighborIndex]!
|
||||
const dx = position[0] - neighbor[0]
|
||||
const dy = position[1] - neighbor[1]
|
||||
const dz = position[2] - neighbor[2]
|
||||
const len = Math.hypot(dx, dy, dz)
|
||||
const direction: [number, number, number] =
|
||||
len < 1e-9 ? [1, 0, 0] : [dx / len, dy / len, dz / len]
|
||||
return {
|
||||
id,
|
||||
nodeId: pipe.id,
|
||||
position,
|
||||
direction,
|
||||
diameter: pipe.diameter,
|
||||
system: pipe.system,
|
||||
}
|
||||
}
|
||||
|
||||
function projectToAngleLock(
|
||||
from: [number, number, number],
|
||||
raw: [number, number, number],
|
||||
@@ -305,11 +332,14 @@ const PipeSegmentTool = () => {
|
||||
...(cross ? [cross.runUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
],
|
||||
})
|
||||
const nextPipe = pipes.at(-1)
|
||||
const nextStart = nextPipe ? nextPipe.path[nextPipe.path.length - 1]! : end
|
||||
const nextPort = nextPipe ? pipeEndPort(nextPipe, 'end') : endPort
|
||||
triggerSFX('sfx:item-place')
|
||||
setDraftStart(null)
|
||||
setDraftStart(nextStart)
|
||||
setSnapTarget(null)
|
||||
startPortRef.current = null
|
||||
startBodyRef.current = null
|
||||
startPortRef.current = nextPort
|
||||
startBodyRef.current = nextPort ? null : endBody
|
||||
altAnchorRef.current = null
|
||||
setAltActive(false)
|
||||
}
|
||||
@@ -478,6 +508,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 +650,7 @@ const PipeSegmentTool = () => {
|
||||
dimension pill rides just above the cursor. */}
|
||||
{cursorPos && (
|
||||
<>
|
||||
<CursorSphere position={cursorPos} />
|
||||
<CursorSphere color={snapTarget ? SNAP_CURSOR_COLOR : undefined} position={cursorPos} />
|
||||
{pillParts && (
|
||||
<group position={cursorPos}>
|
||||
<Html
|
||||
|
||||
@@ -26,7 +26,7 @@ export const pipeTrapDefinition: NodeDefinition<typeof PipeTrapNode> = {
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
diameter: 1.5,
|
||||
diameter: 2,
|
||||
pipeMaterial: 'pvc',
|
||||
armLengthM: 0,
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
emitter,
|
||||
type RidgeVentNode,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
@@ -20,8 +21,13 @@ import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
snapRelativeRoofDragTarget,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getSurfaceY } from '../shared/roof-surface'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfaceNodePlacementGuides,
|
||||
} from '../shared/roof-surface-placement-guides'
|
||||
import RidgeVentPreview from './preview'
|
||||
|
||||
type RidgeVentDragTarget = Pick<RelativeRoofDragTarget, 'segment' | 'localX'> & {
|
||||
@@ -72,11 +78,13 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
lastTarget = null
|
||||
lastSnap = null
|
||||
setPreviewPos(null)
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
|
||||
const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => {
|
||||
const target = roofDrag.resolve(event)
|
||||
if (!target) return null
|
||||
const rawTarget = roofDrag.resolve(event)
|
||||
if (!rawTarget) return null
|
||||
const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true)
|
||||
return {
|
||||
segment: target.segment,
|
||||
localX: target.localX,
|
||||
@@ -111,6 +119,13 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
publishRoofSurfaceNodePlacementGuides({
|
||||
roof: event.node as RoofNode,
|
||||
segment: target.segment,
|
||||
center: [target.localX, target.localY, target.localZ],
|
||||
node,
|
||||
mode: 'linear-edge',
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
@@ -157,6 +172,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
if (obj) obj.visible = true
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -175,6 +191,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
@@ -194,6 +211,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
@@ -223,6 +241,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
clearRoofSurfacePlacementGuides()
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
@@ -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 && (
|
||||
<group position={previewPos}>
|
||||
|
||||
@@ -179,16 +179,20 @@ describe('planTeeAtRunBody', () => {
|
||||
expect(plan!.fitting.diameter2).toBe(6)
|
||||
})
|
||||
|
||||
test('45° drawn branch leaves square (projected perpendicular)', () => {
|
||||
test('45° drawn branch builds a 45° lateral that follows the drawn run', () => {
|
||||
const run = trunk([
|
||||
[0, 0, 0],
|
||||
[6, 0, 0],
|
||||
])
|
||||
const d = Math.SQRT1_2
|
||||
// Drawn 45° downstream off the +X trunk. The tee becomes a lateral whose
|
||||
// branch points along the drawn direction, so the new duct continues
|
||||
// straight out of the collar instead of kinking square.
|
||||
const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [d, 0, d], ROUND_6)
|
||||
expect(plan).not.toBeNull()
|
||||
expect(plan!.fitting.branchAngle).toBeCloseTo(45, 6)
|
||||
const branch = getDuctFittingPorts(plan!.fitting).find((p) => p.id === 'branch')!
|
||||
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
|
||||
expect(dot(branch.direction, [d, 0, d])).toBeCloseTo(1, 6)
|
||||
})
|
||||
|
||||
test('tap too close to a run end → null (use the end port instead)', () => {
|
||||
@@ -502,10 +506,29 @@ describe('planElbowRealign', () => {
|
||||
expect(dot(outlet.direction, [0, 0, 1])).toBeCloseTo(1, 6)
|
||||
})
|
||||
|
||||
test('arrival needing a turn outside 15–90° → null', () => {
|
||||
test('shallow arrival flattens the elbow toward a straight coupling', () => {
|
||||
const elbow = existingElbow()
|
||||
// Away nearly opposite the fixed inlet direction → turn < 15°. Unlike
|
||||
// fresh-fitting creation, an existing elbow flattens to this small angle
|
||||
// instead of bailing, so the run can be dragged dead straight.
|
||||
const plan = planElbowRealign(elbow, 'outlet', [0.99, 0, 0.14])
|
||||
expect(plan).not.toBeNull()
|
||||
expect(plan!.update.data.angle).toBeLessThan(15)
|
||||
expect(plan!.update.data.angle).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('run dragged into line flattens the elbow to a straight 0° coupling', () => {
|
||||
const elbow = existingElbow()
|
||||
// The free outlet pulled exactly opposite the mated inlet → no turn left.
|
||||
const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')!
|
||||
const away: Point = [-inlet.direction[0], -inlet.direction[1], -inlet.direction[2]]
|
||||
const plan = planElbowRealign(elbow, 'outlet', away)
|
||||
expect(plan).not.toBeNull()
|
||||
expect(plan!.update.data.angle).toBeCloseTo(0, 5)
|
||||
})
|
||||
|
||||
test('a back-turn sharper than 90° still bails', () => {
|
||||
const elbow = existingElbow()
|
||||
// Away nearly opposite the fixed inlet direction → turn < 15°.
|
||||
expect(planElbowRealign(elbow, 'outlet', [0.99, 0, 0.14])).toBeNull()
|
||||
// Away aligned WITH the fixed collar direction → turn > 90°.
|
||||
expect(planElbowRealign(elbow, 'outlet', [-0.99, 0, 0.14])).toBeNull()
|
||||
})
|
||||
|
||||
@@ -202,9 +202,10 @@ export type TeeTapPlan = {
|
||||
* upstream half (trimmed one leg short), a new duct-segment node carries
|
||||
* the downstream half (starting one leg after), and the tee's run legs
|
||||
* bridge the gap with its junction exactly on the centerline hit. The
|
||||
* branch collar points along `awayDir` projected perpendicular to the
|
||||
* trunk axis — a tee's branch is square to its run, so a 45° drawn
|
||||
* branch leaves square and the drawn duct continues from the collar.
|
||||
* branch collar follows `awayDir`: the tee becomes a lateral whose
|
||||
* `branchAngle` (clamped to the buildable 45–135° range) matches the turn
|
||||
* the drawn run makes off the trunk, so the new duct continues straight
|
||||
* out of the collar instead of kinking square.
|
||||
*
|
||||
* Returns null when the tap can't be built: too close to the segment's
|
||||
* ends (no room for the run legs — join the end port instead), or the
|
||||
@@ -223,13 +224,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 +270,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 +283,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 +300,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],
|
||||
@@ -462,24 +490,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
|
||||
|
||||
@@ -498,10 +532,14 @@ export function planElbowRealign(
|
||||
)
|
||||
const fixedWorld = snappedPortId === 'inlet' ? outletWorld : inletWorld
|
||||
|
||||
// New turn from the fixed collar / free collar pair.
|
||||
// New turn from the fixed collar / free collar pair. Unlike fresh-fitting
|
||||
// creation (which butt-joins near-straight runs rather than minting a flat
|
||||
// elbow), an EXISTING elbow may flatten all the way to 0° — a straight
|
||||
// coupling — when its run is dragged into line, so only the upper bound
|
||||
// guards here.
|
||||
const spread = fixedWorld.angleTo(away)
|
||||
const turnNew = Math.PI - spread
|
||||
if (turnNew < MIN_TURN_RAD || turnNew > MAX_TURN_RAD) return null
|
||||
if (turnNew > MAX_TURN_RAD) return null
|
||||
|
||||
// Local outward pair at the new angle, ordered (fixed, free) to match
|
||||
// the world pair.
|
||||
@@ -512,23 +550,115 @@ export function planElbowRealign(
|
||||
|
||||
const localFrame = frame(fixedLocal, freeLocal)
|
||||
const worldFrame = frame(fixedWorld, away)
|
||||
if (!localFrame || !worldFrame) return null
|
||||
const rotation = new Quaternion().setFromRotationMatrix(
|
||||
worldFrame.multiply(localFrame.transpose()),
|
||||
)
|
||||
// At (near-)straight the two collars are collinear, so the bend plane is
|
||||
// undefined and `frame()` returns null. Map the fixed collar's local axis
|
||||
// onto its world direction instead; the free collar (antiparallel) lands
|
||||
// on `away` for free, and a straight coupling's roll is arbitrary.
|
||||
const rotation =
|
||||
localFrame && worldFrame
|
||||
? new Quaternion().setFromRotationMatrix(worldFrame.multiply(localFrame.transpose()))
|
||||
: new Quaternion().setFromUnitVectors(fixedLocal, fixedWorld)
|
||||
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.max(0, 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,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
||||
import {
|
||||
AUTO_OFFSET_KEY,
|
||||
type AutoOffsetTag,
|
||||
autoOffsetInvalidationUpdates,
|
||||
newAutoOffsetGroupId,
|
||||
readAutoOffsetTag,
|
||||
translateAutoOffsetBase,
|
||||
withAutoOffsetTag,
|
||||
withoutAutoOffsetTag,
|
||||
} from './auto-offset-tag'
|
||||
|
||||
const sampleTag = (): AutoOffsetTag => ({
|
||||
group: 'aoff_test',
|
||||
dy: 0.6,
|
||||
minted: ['duct-fitting_a' as AnyNodeId, 'duct-segment_r' as AnyNodeId],
|
||||
base: [{ id: 'duct-segment_run' as AnyNodeId, data: { path: [[0, 2, 0]] } }],
|
||||
})
|
||||
|
||||
describe('auto-offset tag round-trip', () => {
|
||||
it('writes then reads back an identical tag', () => {
|
||||
const tag = sampleTag()
|
||||
const meta = withAutoOffsetTag({ existing: 1 }, tag)
|
||||
expect(meta.existing).toBe(1)
|
||||
expect(readAutoOffsetTag({ metadata: meta })).toEqual(tag)
|
||||
})
|
||||
|
||||
it('replaces a prior tag rather than nesting it', () => {
|
||||
const first = sampleTag()
|
||||
const second: AutoOffsetTag = { ...first, dy: 1.2, group: 'aoff_two' }
|
||||
const meta = withAutoOffsetTag(withAutoOffsetTag({}, first), second)
|
||||
expect(readAutoOffsetTag({ metadata: meta })).toEqual(second)
|
||||
})
|
||||
|
||||
it('removes the tag while preserving other metadata keys', () => {
|
||||
const meta = withAutoOffsetTag({ keep: 'me' }, sampleTag())
|
||||
const stripped = withoutAutoOffsetTag(meta)
|
||||
expect(stripped).toEqual({ keep: 'me' })
|
||||
expect(stripped[AUTO_OFFSET_KEY]).toBeUndefined()
|
||||
expect(readAutoOffsetTag({ metadata: stripped })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('translateAutoOffsetBase', () => {
|
||||
it('moves path and position patches with a rigid offset translation', () => {
|
||||
const tag: AutoOffsetTag = {
|
||||
...sampleTag(),
|
||||
base: [
|
||||
{
|
||||
id: 'duct-segment_run' as AnyNodeId,
|
||||
data: {
|
||||
path: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duct-fitting_elbow' as AnyNodeId,
|
||||
data: { position: [4, 1, 5], angle: 90 },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const moved = translateAutoOffsetBase(tag, [1, 0, -2])
|
||||
|
||||
expect(moved.base[0]?.data.path).toEqual([
|
||||
[1, 0, -2],
|
||||
[3, 0, -2],
|
||||
])
|
||||
expect(moved.base[1]?.data.position).toEqual([5, 1, 3])
|
||||
expect(moved.base[1]?.data.angle).toBe(90)
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoOffsetInvalidationUpdates', () => {
|
||||
it('clears owner tags when a generated offset part is edited manually', () => {
|
||||
const owner = {
|
||||
id: 'duct-segment_owner' as AnyNodeId,
|
||||
metadata: withAutoOffsetTag({}, sampleTag()),
|
||||
} as AnyNode
|
||||
const other = {
|
||||
id: 'duct-segment_other' as AnyNodeId,
|
||||
metadata: withAutoOffsetTag({}, { ...sampleTag(), minted: ['duct-fitting_other'] }),
|
||||
} as AnyNode
|
||||
|
||||
const updates = autoOffsetInvalidationUpdates(
|
||||
{
|
||||
[owner.id]: owner,
|
||||
[other.id]: other,
|
||||
},
|
||||
'duct-fitting_a' as AnyNodeId,
|
||||
)
|
||||
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0]?.id).toBe(owner.id)
|
||||
expect(readAutoOffsetTag({ metadata: updates[0]?.data.metadata })).toBeNull()
|
||||
})
|
||||
|
||||
it('clears owner tags when a stored base participant is edited manually', () => {
|
||||
const owner = {
|
||||
id: 'duct-segment_owner' as AnyNodeId,
|
||||
metadata: withAutoOffsetTag(
|
||||
{},
|
||||
{
|
||||
...sampleTag(),
|
||||
base: [
|
||||
{ id: 'duct-segment_owner' as AnyNodeId, data: { path: [[0, 0, 0]] } },
|
||||
{ id: 'duct-fitting_corner' as AnyNodeId, data: { position: [1, 0, 0] } },
|
||||
],
|
||||
},
|
||||
),
|
||||
} as AnyNode
|
||||
|
||||
const updates = autoOffsetInvalidationUpdates(
|
||||
{ [owner.id]: owner },
|
||||
'duct-fitting_corner' as AnyNodeId,
|
||||
)
|
||||
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0]?.id).toBe(owner.id)
|
||||
expect(readAutoOffsetTag({ metadata: updates[0]?.data.metadata })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readAutoOffsetTag guards', () => {
|
||||
it('returns null for missing / empty metadata', () => {
|
||||
expect(readAutoOffsetTag(null)).toBeNull()
|
||||
expect(readAutoOffsetTag(undefined)).toBeNull()
|
||||
expect(readAutoOffsetTag({})).toBeNull()
|
||||
expect(readAutoOffsetTag({ metadata: {} })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a malformed tag (wrong field shapes)', () => {
|
||||
const bad = [
|
||||
{ group: 1, dy: 0, minted: [], base: [] },
|
||||
{ group: 'g', dy: 'x', minted: [], base: [] },
|
||||
{ group: 'g', dy: 0, minted: 'nope', base: [] },
|
||||
{ group: 'g', dy: 0, minted: [], base: {} },
|
||||
]
|
||||
for (const tag of bad) {
|
||||
expect(readAutoOffsetTag({ metadata: { [AUTO_OFFSET_KEY]: tag } })).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('newAutoOffsetGroupId', () => {
|
||||
it('produces a prefixed, unique-ish id', () => {
|
||||
const a = newAutoOffsetGroupId()
|
||||
const b = newAutoOffsetGroupId()
|
||||
expect(a.startsWith('aoff_')).toBe(true)
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Tag + rewind bookkeeping for auto-routed vertical offsets.
|
||||
*
|
||||
* When a connected duct run is lifted with the run-center ±Y arrows, the
|
||||
* planner welds it back to its stationary partner with an auto-routed Z/S
|
||||
* offset — elbows + a plumb riser (see `vertical-offset.ts`). On commit we
|
||||
* stamp the LIFTED RUN with an `autoOffset` tag in its `metadata` recording:
|
||||
* - the minted nodes (elbows + risers) that formed the offset, and
|
||||
* - the `base` patches that restore the run + its partners to the LOGICAL L
|
||||
* they sprang from (the canonical corner, before any offset).
|
||||
*
|
||||
* That tag lets a LATER drag dissolve the offset and replan from the clean L:
|
||||
* at drag start we rewind (delete the minted nodes, apply the base patches),
|
||||
* plan a fresh offset from the logical L, and commit the result — so dragging
|
||||
* back toward the original height collapses the Z back to an L, and re-lifting
|
||||
* forms a new one. The `base` moves when the whole tagged offset is translated
|
||||
* and is refreshed when fitting edits retarget its collars; otherwise a later
|
||||
* re-drag would rewind to stale geometry.
|
||||
*
|
||||
* The tag lives only on the run (detection keys off the dragged run), not on
|
||||
* the minted fittings / risers.
|
||||
*/
|
||||
|
||||
/** Key under a node's `metadata` JSON bag where the offset tag is stored. */
|
||||
export const AUTO_OFFSET_KEY = 'autoOffset'
|
||||
|
||||
/** A logical-L restore patch: a node id plus the field subset that returns it
|
||||
* to its pre-offset pose (a run's `path`, or a fitting's `position` /
|
||||
* `rotation` / `angle`). */
|
||||
export type AutoOffsetBasePatch = { id: AnyNodeId; data: Record<string, unknown> }
|
||||
|
||||
export type AutoOffsetTag = {
|
||||
/** Stable id shared by every node in this offset (currently only the run
|
||||
* carries the tag, but the group id lets future selections relate them). */
|
||||
group: string
|
||||
/** The vertical lift (meters, signed) from the logical L that formed this
|
||||
* offset. A re-drag plans from the L with `dy + delta`, so grabbing the run
|
||||
* with no movement reproduces this exact Z, and dragging it down by `dy`
|
||||
* lands back on the L. Invariant inputs (L + dy) make the re-plan match the
|
||||
* committed geometry. */
|
||||
dy: number
|
||||
/** The elbows + risers minted to form this offset — deleted on rewind. */
|
||||
minted: AnyNodeId[]
|
||||
/** Patches restoring the run + partners to the current logical L. */
|
||||
base: AutoOffsetBasePatch[]
|
||||
}
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
function metaRecord(metadata: unknown): Record<string, unknown> {
|
||||
return metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function isPoint(value: unknown): value is Point {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length >= 3 &&
|
||||
typeof value[0] === 'number' &&
|
||||
typeof value[1] === 'number' &&
|
||||
typeof value[2] === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function translatePoint(point: Point, delta: Point): Point {
|
||||
return [point[0] + delta[0], point[1] + delta[1], point[2] + delta[2]]
|
||||
}
|
||||
|
||||
/** The offset tag on `node`, or null if it carries none / a malformed one. */
|
||||
export function readAutoOffsetTag(
|
||||
node: { metadata?: unknown } | null | undefined,
|
||||
): AutoOffsetTag | null {
|
||||
const tag = metaRecord(node?.metadata)[AUTO_OFFSET_KEY] as Partial<AutoOffsetTag> | undefined
|
||||
if (!tag || typeof tag !== 'object') return null
|
||||
if (
|
||||
typeof tag.group !== 'string' ||
|
||||
typeof tag.dy !== 'number' ||
|
||||
!Array.isArray(tag.minted) ||
|
||||
!Array.isArray(tag.base)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return tag as AutoOffsetTag
|
||||
}
|
||||
|
||||
/** `metadata` with the offset tag set (replacing any prior one). */
|
||||
export function withAutoOffsetTag(metadata: unknown, tag: AutoOffsetTag): Record<string, unknown> {
|
||||
return { ...metaRecord(metadata), [AUTO_OFFSET_KEY]: tag }
|
||||
}
|
||||
|
||||
/** `metadata` with the offset tag removed — the run is a clean L again. */
|
||||
export function withoutAutoOffsetTag(metadata: unknown): Record<string, unknown> {
|
||||
const { [AUTO_OFFSET_KEY]: _omit, ...rest } = metaRecord(metadata)
|
||||
return rest
|
||||
}
|
||||
|
||||
/** Translate the logical-L base when the whole tagged offset is moved rigidly. */
|
||||
export function translateAutoOffsetBase(tag: AutoOffsetTag, delta: Point): AutoOffsetTag {
|
||||
return {
|
||||
...tag,
|
||||
base: tag.base.map((patch) => {
|
||||
const data = { ...patch.data }
|
||||
if (Array.isArray(data.path)) {
|
||||
data.path = data.path.map((point) =>
|
||||
isPoint(point) ? translatePoint(point, delta) : point,
|
||||
)
|
||||
}
|
||||
if (isPoint(data.position)) {
|
||||
data.position = translatePoint(data.position, delta)
|
||||
}
|
||||
return { ...patch, data }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Scene updates that drop auto-offset ownership when a participating part is edited manually. */
|
||||
export function autoOffsetInvalidationUpdates(
|
||||
nodes: Record<string, AnyNode>,
|
||||
editedNodeId: AnyNodeId,
|
||||
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
|
||||
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
||||
for (const node of Object.values(nodes)) {
|
||||
const tag = readAutoOffsetTag(node)
|
||||
const participates =
|
||||
tag?.minted.includes(editedNodeId) || tag?.base.some((patch) => patch.id === editedNodeId)
|
||||
if (!participates) continue
|
||||
updates.push({
|
||||
id: node.id as AnyNodeId,
|
||||
data: { metadata: withoutAutoOffsetTag(node.metadata) } as Partial<AnyNode>,
|
||||
})
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
/** A fresh, scene-unique-enough group id for a newly minted offset. */
|
||||
export function newAutoOffsetGroupId(): string {
|
||||
return `aoff_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SlotDeclaration } from '@pascal-app/core'
|
||||
import { createSlotPaintCapability, previewGeometrySlot } from './slot-paint'
|
||||
|
||||
export const DUCT_BODY_SLOT_ID = 'body'
|
||||
export const DUCT_BODY_SLOT_DEFAULT = '#ffffff'
|
||||
|
||||
export function ductBodySlots(): SlotDeclaration[] {
|
||||
return [{ slotId: DUCT_BODY_SLOT_ID, label: 'Body', default: DUCT_BODY_SLOT_DEFAULT }]
|
||||
}
|
||||
|
||||
export const ductBodyPaint = createSlotPaintCapability({
|
||||
resolveRole: ({ hitObject }) => {
|
||||
const slotId = (hitObject?.userData as { slotId?: unknown } | undefined)?.slotId
|
||||
return slotId === DUCT_BODY_SLOT_ID ? DUCT_BODY_SLOT_ID : null
|
||||
},
|
||||
applyPreview: previewGeometrySlot,
|
||||
})
|
||||
@@ -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<AnyNode> }
|
||||
}
|
||||
|
||||
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<AnyNode> }
|
||||
}
|
||||
|
||||
/** 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<readonly [number, number, number]>,
|
||||
draggedIndex: number,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): 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<AnyNode>,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
fitting,
|
||||
reaim: 'tee-branch',
|
||||
portId: 'branch',
|
||||
fittingType,
|
||||
revert: {
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: { branchAngle: (fitting as DuctFittingNode).branchAngle } as Partial<AnyNode>,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
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<AnyNode>,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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<AnyNode>,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
PipeFittingNode,
|
||||
PipeSegmentNode,
|
||||
} 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'
|
||||
import { buildPipeFittingGeometry } from '../pipe-fitting/geometry'
|
||||
import { buildPipeSegmentGeometry } from '../pipe-segment/geometry'
|
||||
import { INVALID_GHOST_COLOR, VALID_GHOST_COLOR } from './ghost-materials'
|
||||
|
||||
/** Indigo-400 — the shared MEP preview accent (matches the draw-tool ghost). */
|
||||
export const GHOST_COLOR = '#818cf8'
|
||||
export const GHOST_OPACITY = 0.55
|
||||
|
||||
/** Tint state for an auto-routed offset preview: green = a buildable offset
|
||||
* that will mint on release, red = no valid offset at this height (the run
|
||||
* lifts as a preview only and snaps back). Undefined = the neutral indigo
|
||||
* preview used everywhere else. */
|
||||
export type GhostTint = 'valid' | 'invalid' | undefined
|
||||
|
||||
function ghostColor(tint: GhostTint): number | string {
|
||||
if (tint === 'valid') return VALID_GHOST_COLOR
|
||||
if (tint === 'invalid') return INVALID_GHOST_COLOR
|
||||
return GHOST_COLOR
|
||||
}
|
||||
|
||||
/** Repaint every mesh in `group` as a translucent, depth-test-free preview. */
|
||||
function ghostify(group: { traverse: (cb: (child: object) => void) => void }, tint: GhostTint) {
|
||||
const color = ghostColor(tint)
|
||||
group.traverse((child) => {
|
||||
if (child instanceof Mesh) {
|
||||
child.layers.set(EDITOR_LAYER)
|
||||
child.material = new MeshBasicMaterial({
|
||||
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, tint }: { fitting: DuctFittingNode; tint?: GhostTint }) {
|
||||
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, tint)
|
||||
return group
|
||||
}, [fitting, tint])
|
||||
return <primitive object={ghost} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, tint }: { duct: DuctSegmentNode; tint?: GhostTint }) {
|
||||
const ghost = useMemo(() => {
|
||||
const group = buildDuctSegmentGeometry(duct)
|
||||
ghostify(group, tint)
|
||||
return group
|
||||
}, [duct, tint])
|
||||
return <primitive object={ghost} />
|
||||
}
|
||||
|
||||
export function PipeFittingGhost({
|
||||
fitting,
|
||||
tint,
|
||||
}: {
|
||||
fitting: PipeFittingNode
|
||||
tint?: GhostTint
|
||||
}) {
|
||||
const ghost = useMemo(() => {
|
||||
const group = buildPipeFittingGeometry(fitting)
|
||||
group.position.set(...fitting.position)
|
||||
group.rotation.set(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2])
|
||||
ghostify(group, tint)
|
||||
return group
|
||||
}, [fitting, tint])
|
||||
return <primitive object={ghost} />
|
||||
}
|
||||
|
||||
export function PipeSegmentGhost({ pipe, tint }: { pipe: PipeSegmentNode; tint?: GhostTint }) {
|
||||
const ghost = useMemo(() => {
|
||||
const group = buildPipeSegmentGeometry(pipe)
|
||||
ghostify(group, tint)
|
||||
return group
|
||||
}, [pipe, tint])
|
||||
return <primitive object={ghost} />
|
||||
}
|
||||
@@ -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 {
|
||||
detectFittingEndpoint,
|
||||
type FittingEndpoint,
|
||||
planFittingEndpointReaim,
|
||||
} from './fitting-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 / 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.
|
||||
*
|
||||
* 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<N extends PathShape & { id: AnyNod
|
||||
},
|
||||
}
|
||||
return {
|
||||
start({ node, payload }): FloorplanAffordanceSession {
|
||||
start({ node, payload, nodes }): FloorplanAffordanceSession {
|
||||
const { pointIndex } = payload as PathPointPayload
|
||||
const initialPath = node.path.map((p) => [...p] as [number, number, number])
|
||||
const target = initialPath[pointIndex]
|
||||
@@ -41,18 +60,78 @@ export function createPathPointMoveAffordance<N extends PathShape & { id: AnyNod
|
||||
// Hold the dragged vertex's elevation — the plan move only shifts XZ.
|
||||
const y = target[1]
|
||||
|
||||
// Connectivity snapshot: which fittings / runs are mated to this run's
|
||||
// endpoints so they follow the drag. Only endpoints (first / last vertex)
|
||||
// bear ports; interior vertices have no joint, so skip the analysis.
|
||||
const isEndpoint = pointIndex === 0 || pointIndex === initialPath.length - 1
|
||||
|
||||
// Fitting re-aim (duct / pipe): if this is a straight run whose OTHER
|
||||
// end sits on an elbow collar (bend angle adapts) or a duct tee branch
|
||||
// collar (branch lean adapts), the fitting swings to follow the drag —
|
||||
// the 2D twin of the 3D selection handle's behaviour. Takes precedence
|
||||
// over the rigid connectivity follow for this endpoint.
|
||||
const fittingEndpoint: FittingEndpoint | null = isEndpoint
|
||||
? detectFittingEndpoint(kind, initialPath, pointIndex, nodes)
|
||||
: null
|
||||
|
||||
const connectivity: PortConnectivity | null =
|
||||
isEndpoint && !fittingEndpoint
|
||||
? analyzePortConnectivity(node as unknown as AnyNode, nodes)
|
||||
: null
|
||||
|
||||
// Report every node the drag may write so the dispatcher snapshots them
|
||||
// for the single-undo dance.
|
||||
const affectedIds: AnyNodeId[] = [
|
||||
node.id,
|
||||
...(fittingEndpoint ? [fittingEndpoint.fitting.id as AnyNodeId] : []),
|
||||
...(connectivity?.connections.map((c) => c.nodeId) ?? []),
|
||||
]
|
||||
|
||||
const followUpdates = (nextPath: [number, number, number][]) => {
|
||||
if (!connectivity) return []
|
||||
const preview = {
|
||||
...(node as unknown as Record<string, unknown>),
|
||||
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<unknown> 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
|
||||
// Fitting re-aim: the fitting swings to follow the dragged end and
|
||||
// the run rides its re-aimed collar. Out-of-range turns hold the
|
||||
// frame.
|
||||
if (!detached && fittingEndpoint) {
|
||||
const plan = planFittingEndpointReaim(fittingEndpoint, pointIndex, dragged)
|
||||
if (!plan) return
|
||||
useScene.getState().updateNodes([
|
||||
{ id: node.id, data: { path: plan.path } as Partial<unknown> as never },
|
||||
{
|
||||
id: plan.fittingUpdate.id,
|
||||
data: plan.fittingUpdate.data as Partial<unknown> as never,
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
const nextPath = initialPath.map((p, i) => (i === pointIndex ? dragged : p))
|
||||
useScene.getState().updateNodes([
|
||||
{ id: node.id, data: { path: nextPath } as Partial<unknown> as never },
|
||||
...(detached ? [] : followUpdates(nextPath)).map((u) => ({
|
||||
id: u.id,
|
||||
data: u.data as Partial<unknown> as never,
|
||||
})),
|
||||
])
|
||||
},
|
||||
canCommit() {
|
||||
const final = useScene.getState().nodes[node.id] as N | undefined
|
||||
|
||||
@@ -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<readonly [number, number, number]>; 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<N extends PathShape>(
|
||||
node: N,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
analyze: boolean,
|
||||
): {
|
||||
connectivity: PortConnectivity | null
|
||||
affectedIds: AnyNodeId[]
|
||||
followUpdates: (nextPath: Point[]) => { id: AnyNodeId; data: Partial<AnyNode> }[]
|
||||
} {
|
||||
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<string, unknown>),
|
||||
path: nextPath,
|
||||
} as AnyNode
|
||||
return resolveConnectivityUpdates(connectivity, preview).filter(
|
||||
(u) => useScene.getState().nodes[u.id],
|
||||
)
|
||||
}
|
||||
return { connectivity, affectedIds, followUpdates }
|
||||
}
|
||||
|
||||
export function createSegmentMoveAffordance<N extends PathShape>(
|
||||
kind: string,
|
||||
): FloorplanAffordance<N> {
|
||||
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<unknown> as never },
|
||||
...followUpdates(nextPath).map((u) => ({
|
||||
id: u.id,
|
||||
data: u.data as Partial<unknown> 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
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
PipeFittingNode,
|
||||
PipeSegmentNode,
|
||||
type PortConnection,
|
||||
} from '@pascal-app/core'
|
||||
import { getPipeFittingPorts } from '../pipe-fitting/ports'
|
||||
import { planPipeElbowAtPort } from './auto-fitting'
|
||||
import { planPipeRunTranslationOffsets } from './pipe-run-translation-offset'
|
||||
import type { ScenePort } from './ports'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
function drain(path: Point[]): PipeSegmentNode {
|
||||
return PipeSegmentNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Drain',
|
||||
path,
|
||||
diameter: 3,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
})
|
||||
}
|
||||
|
||||
function runConnection(run: PipeSegmentNode): PortConnection {
|
||||
return {
|
||||
kind: 'run',
|
||||
nodeId: run.id,
|
||||
startPath: run.path,
|
||||
}
|
||||
}
|
||||
|
||||
function fittingConnection(fitting: PipeFittingNode): PortConnection {
|
||||
return {
|
||||
kind: 'rigid-node',
|
||||
nodeId: fitting.id,
|
||||
startPosition: fitting.position,
|
||||
}
|
||||
}
|
||||
|
||||
function runPort(run: PipeSegmentNode, point: Point, direction: Point): ScenePort {
|
||||
return {
|
||||
id: 'end',
|
||||
nodeId: run.id,
|
||||
position: point,
|
||||
direction,
|
||||
diameter: run.diameter,
|
||||
system: run.system,
|
||||
}
|
||||
}
|
||||
|
||||
function portLike(position: Point, direction: Point): ScenePort {
|
||||
return {
|
||||
id: 'x',
|
||||
nodeId: 'x' as AnyNode['id'],
|
||||
position,
|
||||
direction,
|
||||
diameter: 3,
|
||||
system: 'waste',
|
||||
}
|
||||
}
|
||||
|
||||
function distSq(a: readonly number[], b: 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
|
||||
}
|
||||
|
||||
describe('planPipeRunTranslationOffsets', () => {
|
||||
test('slides a connected pipe sideways by adding bends and a connector', () => {
|
||||
const moved = drain([
|
||||
[0, 0, 0],
|
||||
[4, 0, 0],
|
||||
])
|
||||
const partner = drain([
|
||||
[-4, 0, 0],
|
||||
[0, 0, 0],
|
||||
])
|
||||
const translatedPath = moved.path.map((p) => [p[0], p[1], p[2] - 1.2] as Point)
|
||||
|
||||
const result = planPipeRunTranslationOffsets({
|
||||
pipe: moved,
|
||||
translatedPath,
|
||||
profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial },
|
||||
connections: [runConnection(partner)],
|
||||
scenePorts: [runPort(partner, [0, 0, 0], [1, 0, 0])],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[partner.id]: partner as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
if (!result) return
|
||||
expect(result.fittings).toHaveLength(2)
|
||||
expect(result.connectors).toHaveLength(1)
|
||||
expect(result.updates.some((u) => u.id === partner.id)).toBe(true)
|
||||
expect(result.pipePath[0]![2]).toBeLessThan(0)
|
||||
})
|
||||
|
||||
test('re-aims an existing pipe elbow and inserts the missing connector', () => {
|
||||
const elbowPlan = planPipeElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 0, -1], 3, 'pvc')
|
||||
expect(elbowPlan).toBeTruthy()
|
||||
if (!elbowPlan) return
|
||||
const elbow = PipeFittingNode.parse(elbowPlan.fitting)
|
||||
const branchPort = getPipeFittingPorts(elbow).find(
|
||||
(p) => distSq(p.position, elbowPlan.collarPoint) < 1e-9,
|
||||
)!
|
||||
const moved = drain([
|
||||
[...branchPort.position],
|
||||
[branchPort.position[0] + 4, branchPort.position[1], branchPort.position[2]],
|
||||
])
|
||||
const translatedPath = moved.path.map((p) => [p[0], p[1], p[2] - 1.2] as Point)
|
||||
|
||||
const result = planPipeRunTranslationOffsets({
|
||||
pipe: moved,
|
||||
translatedPath,
|
||||
profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial },
|
||||
connections: [fittingConnection(elbow)],
|
||||
scenePorts: [{ ...branchPort, nodeId: elbow.id }],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[elbow.id]: elbow as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
if (!result) return
|
||||
expect(result.fittings).toHaveLength(1)
|
||||
expect(result.connectors).toHaveLength(1)
|
||||
expect(result.updates.some((u) => u.id === elbow.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
PipeSegmentNode,
|
||||
type PortConnection,
|
||||
} from '@pascal-app/core'
|
||||
import { pipeFittingLegLength } from '../pipe-fitting/ports'
|
||||
import type { PipeFittingNode } from '../pipe-fitting/schema'
|
||||
import { planPipeElbowAtPort, planPipeElbowRealign } from './auto-fitting'
|
||||
import type { ScenePort } from './ports'
|
||||
|
||||
type Point = [number, number, number]
|
||||
type PipeProfile = {
|
||||
diameter: number
|
||||
pipeMaterial: PipeFittingNode['pipeMaterial']
|
||||
}
|
||||
|
||||
const COINCIDENT_EPS_M = 0.05
|
||||
const MIN_CONNECTOR_M = 0.05
|
||||
|
||||
export type PipeRunTranslationOffsetPlan = {
|
||||
pipePath: Point[]
|
||||
fittings: PipeFittingNode[]
|
||||
connectors: PipeSegmentNode[]
|
||||
updates: { id: AnyNodeId; data: Partial<AnyNode> }[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function sub(a: Point, b: Point): Point {
|
||||
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
function neg(v: Point): Point {
|
||||
return [-v[0], -v[1], -v[2]]
|
||||
}
|
||||
|
||||
function unit(v: Point): Point | null {
|
||||
const len = Math.hypot(v[0], v[1], v[2])
|
||||
if (len < 1e-9) return null
|
||||
return [v[0] / len, v[1] / len, v[2] / len]
|
||||
}
|
||||
|
||||
function endpointOutwardDir(path: ReadonlyArray<readonly number[]>, idx: number): Point {
|
||||
const last = path.length - 1
|
||||
const [a, b] = idx === 0 ? [path[0]!, path[1]!] : [path[last]!, path[last - 1]!]
|
||||
return unit([a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!]) ?? [1, 0, 0]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function connectorRun(from: Point, to: Point, pipe: PipeSegmentNode): PipeSegmentNode {
|
||||
return PipeSegmentNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: pipe.name ?? 'Pipe run',
|
||||
path: [from, to],
|
||||
diameter: pipe.diameter,
|
||||
pipeMaterial: pipe.pipeMaterial,
|
||||
system: pipe.system,
|
||||
})
|
||||
}
|
||||
|
||||
function pipeElbowProfilePatch(profile: PipeProfile): Partial<PipeFittingNode> {
|
||||
return {
|
||||
diameter: profile.diameter,
|
||||
diameter2: profile.diameter,
|
||||
pipeMaterial: profile.pipeMaterial,
|
||||
}
|
||||
}
|
||||
|
||||
export function planPipeRunTranslationOffsets(args: {
|
||||
pipe: PipeSegmentNode
|
||||
translatedPath: Point[]
|
||||
profile: PipeProfile
|
||||
connections: PortConnection[]
|
||||
scenePorts: ScenePort[]
|
||||
nodesById: Record<string, AnyNode>
|
||||
}): PipeRunTranslationOffsetPlan | null {
|
||||
const { pipe, translatedPath, profile, connections, scenePorts, nodesById } = args
|
||||
if (pipe.path.length < 2 || translatedPath.length !== pipe.path.length) return null
|
||||
if (connections.length === 0) return null
|
||||
|
||||
const leg = pipeFittingLegLength(profile.diameter)
|
||||
const minOffset = 2 * leg + MIN_CONNECTOR_M
|
||||
const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M
|
||||
const pipePath = translatedPath.map((p) => [...p] as Point)
|
||||
const fittings: PipeFittingNode[] = []
|
||||
const connectors: PipeSegmentNode[] = []
|
||||
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
||||
let routedAny = false
|
||||
|
||||
for (const endIdx of pipe.path.length > 1 ? [0, pipe.path.length - 1] : [0]) {
|
||||
const startEnd = pipe.path[endIdx]!
|
||||
const movedEnd = translatedPath[endIdx]!
|
||||
const delta = sub(movedEnd, startEnd)
|
||||
const offsetDir = unit(delta)
|
||||
if (!offsetDir || Math.hypot(delta[0], delta[1], delta[2]) < minOffset) continue
|
||||
|
||||
const partnerPort = scenePorts.find(
|
||||
(sp) =>
|
||||
distSq(sp.position, startEnd) <= eps2 &&
|
||||
connections.some((conn) => conn.nodeId === sp.nodeId),
|
||||
)
|
||||
if (!partnerPort) continue
|
||||
const conn = connections.find((c) => c.nodeId === partnerPort.nodeId)
|
||||
if (!conn) continue
|
||||
|
||||
const pipePortDir = endpointOutwardDir(translatedPath, endIdx)
|
||||
const top = planPipeElbowAtPort(
|
||||
portLike(movedEnd, pipePortDir, pipe.system),
|
||||
neg(offsetDir),
|
||||
profile.diameter,
|
||||
profile.pipeMaterial,
|
||||
)
|
||||
if (!top) return null
|
||||
|
||||
if (conn.kind === 'run') {
|
||||
const bottom = planPipeElbowAtPort(
|
||||
portLike(
|
||||
[startEnd[0], startEnd[1], startEnd[2]],
|
||||
[partnerPort.direction[0], partnerPort.direction[1], partnerPort.direction[2]],
|
||||
pipe.system,
|
||||
),
|
||||
offsetDir,
|
||||
profile.diameter,
|
||||
profile.pipeMaterial,
|
||||
)
|
||||
if (!bottom) return null
|
||||
fittings.push(bottom.fitting, top.fitting)
|
||||
connectors.push(connectorRun(bottom.collarPoint, top.collarPoint, pipe))
|
||||
pipePath[endIdx] = top.trimmedPortPoint
|
||||
|
||||
const path = conn.startPath.map((p) => [...p] as Point)
|
||||
const tip = path.findIndex((p) => distSq(p, startEnd) <= eps2)
|
||||
if (tip !== -1) {
|
||||
path[tip] = bottom.trimmedPortPoint
|
||||
updates.push({ id: conn.nodeId, data: { path } as Partial<AnyNode> })
|
||||
}
|
||||
routedAny = true
|
||||
continue
|
||||
}
|
||||
|
||||
const partner = nodesById[conn.nodeId]
|
||||
if (!partner || partner.type !== 'pipe-fitting') return null
|
||||
const elbow = {
|
||||
...(partner as PipeFittingNode),
|
||||
...pipeElbowProfilePatch(profile),
|
||||
} as PipeFittingNode
|
||||
if (elbow.fittingType !== 'elbow') return null
|
||||
const realign = planPipeElbowRealign(elbow, partnerPort.id, offsetDir)
|
||||
if (!realign) return null
|
||||
|
||||
fittings.push(top.fitting)
|
||||
connectors.push(connectorRun(realign.collarPoint, top.collarPoint, pipe))
|
||||
pipePath[endIdx] = top.trimmedPortPoint
|
||||
updates.push({
|
||||
id: elbow.id,
|
||||
data: { ...pipeElbowProfilePatch(profile), ...realign.update.data } as Partial<AnyNode>,
|
||||
})
|
||||
routedAny = true
|
||||
}
|
||||
|
||||
if (!routedAny) return null
|
||||
return { pipePath, fittings, connectors, updates }
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
PipeFittingNode,
|
||||
PipeSegmentNode,
|
||||
type PortConnection,
|
||||
} from '@pascal-app/core'
|
||||
import { getPipeFittingPorts } from '../pipe-fitting/ports'
|
||||
import { planPipeElbowAtPort } from './auto-fitting'
|
||||
import { planVerticalOffsets } from './pipe-vertical-offset'
|
||||
import type { ScenePort } from './ports'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
function distSq(a: readonly number[], b: 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
|
||||
}
|
||||
|
||||
function drain(path: Point[]): PipeSegmentNode {
|
||||
return PipeSegmentNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Drain',
|
||||
path,
|
||||
diameter: 3,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
})
|
||||
}
|
||||
|
||||
function portLike(position: Point, direction: Point): ScenePort {
|
||||
return {
|
||||
id: 'x',
|
||||
nodeId: 'x' as AnyNode['id'],
|
||||
position,
|
||||
direction,
|
||||
diameter: 3,
|
||||
system: 'waste',
|
||||
}
|
||||
}
|
||||
|
||||
function runConnection(run: PipeSegmentNode): PortConnection {
|
||||
return {
|
||||
kind: 'run',
|
||||
nodeId: run.id,
|
||||
startPath: run.path,
|
||||
}
|
||||
}
|
||||
|
||||
function fittingConnection(fitting: PipeFittingNode): PortConnection {
|
||||
return {
|
||||
kind: 'rigid-node',
|
||||
nodeId: fitting.id,
|
||||
startPosition: fitting.position,
|
||||
}
|
||||
}
|
||||
|
||||
function runPort(run: PipeSegmentNode, point: Point, direction: Point): ScenePort {
|
||||
return {
|
||||
id: 'end',
|
||||
nodeId: run.id,
|
||||
position: point,
|
||||
direction,
|
||||
diameter: run.diameter,
|
||||
system: run.system,
|
||||
}
|
||||
}
|
||||
|
||||
function branchFitting(fittingType: 'wye' | 'sanitary-tee' | 'cross'): PipeFittingNode {
|
||||
return PipeFittingNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: fittingType,
|
||||
fittingType,
|
||||
diameter: 3,
|
||||
diameter2: 3,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
angle: 90,
|
||||
})
|
||||
}
|
||||
|
||||
describe('planPipeVerticalOffsets', () => {
|
||||
test('mints a pipe bend-riser-bend offset for a run-connected lift', () => {
|
||||
const moved = drain([
|
||||
[0, 0, 0],
|
||||
[4, 0, 0],
|
||||
])
|
||||
const partner = drain([
|
||||
[-4, 0, 0],
|
||||
[0, 0, 0],
|
||||
])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
pipe: moved,
|
||||
dy: 1.2,
|
||||
profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial },
|
||||
connections: [runConnection(partner)],
|
||||
scenePorts: [runPort(partner, [0, 0, 0], [1, 0, 0])],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[partner.id]: partner as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.fittings).toHaveLength(2)
|
||||
expect(result.plan.risers).toHaveLength(1)
|
||||
expect(result.plan.fittings.every((f) => f.type === 'pipe-fitting')).toBe(true)
|
||||
expect(result.plan.risers[0]?.type).toBe('pipe-segment')
|
||||
})
|
||||
|
||||
test('re-aims an existing pipe elbow before routing the vertical L', () => {
|
||||
const elbow = PipeFittingNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Bend',
|
||||
fittingType: 'elbow',
|
||||
diameter: 3,
|
||||
diameter2: 3,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
angle: 90,
|
||||
})
|
||||
const inlet = getPipeFittingPorts(elbow).find((p) => p.id === 'inlet')!
|
||||
const moved = drain([
|
||||
[...inlet.position],
|
||||
[inlet.position[0] - 4, inlet.position[1], inlet.position[2]],
|
||||
])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
pipe: moved,
|
||||
dy: 1.2,
|
||||
profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial },
|
||||
connections: [fittingConnection(elbow)],
|
||||
scenePorts: [{ ...inlet, nodeId: elbow.id }],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[elbow.id]: elbow as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.fittings).toHaveLength(1)
|
||||
expect(result.plan.risers).toHaveLength(1)
|
||||
expect(result.plan.updates.some((u) => u.id === elbow.id)).toBe(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ fittingType: 'wye' as const, portId: 'branch' },
|
||||
{ fittingType: 'sanitary-tee' as const, portId: 'branch' },
|
||||
{ fittingType: 'cross' as const, portId: 'branch' },
|
||||
])('routes a vertical offset from a stationary $fittingType collar', ({
|
||||
fittingType,
|
||||
portId,
|
||||
}) => {
|
||||
const fitting = branchFitting(fittingType)
|
||||
const ports = getPipeFittingPorts(fitting)
|
||||
const branch = ports.find((p) => p.id === portId)!
|
||||
const moved = drain([
|
||||
[...branch.position],
|
||||
[
|
||||
branch.position[0] + branch.direction[0] * 4,
|
||||
branch.position[1] + branch.direction[1] * 4,
|
||||
branch.position[2] + branch.direction[2] * 4,
|
||||
],
|
||||
])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
pipe: moved,
|
||||
dy: 1.2,
|
||||
profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial },
|
||||
connections: [fittingConnection(fitting)],
|
||||
scenePorts: ports.map((p) => ({ ...p, nodeId: fitting.id })),
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.fittings).toHaveLength(2)
|
||||
expect(result.plan.risers).toHaveLength(1)
|
||||
expect(result.plan.updates.some((u) => u.id === fitting.id)).toBe(false)
|
||||
|
||||
const bottomPorts = getPipeFittingPorts(result.plan.fittings[0]!)
|
||||
const topPorts = getPipeFittingPorts(result.plan.fittings[1]!)
|
||||
const riser = result.plan.risers[0]!
|
||||
expect(bottomPorts.some((p) => distSq(p.position, branch.position) < 1e-9)).toBe(true)
|
||||
expect(bottomPorts.some((p) => distSq(p.position, riser.path[0]!) < 1e-9)).toBe(true)
|
||||
expect(topPorts.some((p) => distSq(p.position, riser.path[1]!) < 1e-9)).toBe(true)
|
||||
expect(topPorts.some((p) => distSq(p.position, result.plan.pipePath[0]!) < 1e-9)).toBe(true)
|
||||
})
|
||||
|
||||
test('continues routing after a pipe riser collapse without needing a new drag', () => {
|
||||
const bottom = planPipeElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], 3, 'pvc')
|
||||
expect(bottom).toBeTruthy()
|
||||
if (!bottom) return
|
||||
|
||||
const bottomPorts = getPipeFittingPorts(bottom.fitting)
|
||||
const riserTop: Point = [bottom.collarPoint[0], 1.2, bottom.collarPoint[2]]
|
||||
const riser = drain([bottom.collarPoint, riserTop])
|
||||
const topRun = drain([riserTop, [4, riserTop[1], riserTop[2]]])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
pipe: topRun,
|
||||
dy: -2.4,
|
||||
profile: { diameter: topRun.diameter, pipeMaterial: topRun.pipeMaterial },
|
||||
connections: [runConnection(riser), fittingConnection(bottom.fitting)],
|
||||
scenePorts: [
|
||||
...bottomPorts.map((p) => ({ ...p, nodeId: bottom.fitting.id })),
|
||||
runPort(riser, bottom.collarPoint, [0, -1, 0]),
|
||||
runPort(riser, riserTop, [0, 1, 0]),
|
||||
],
|
||||
nodesById: {
|
||||
[topRun.id]: topRun as AnyNode,
|
||||
[riser.id]: riser as AnyNode,
|
||||
[bottom.fitting.id]: bottom.fitting as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.dy).toBeCloseTo(-2.4, 6)
|
||||
expect(result.plan.delete).toEqual(expect.arrayContaining([riser.id]))
|
||||
expect(result.plan.fittings.length).toBeGreaterThan(0)
|
||||
expect(result.plan.risers.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, unknown>), 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)', () => {
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
analyzePortConnectivity,
|
||||
type Cursor,
|
||||
type LinesetNode,
|
||||
type LiquidLineNode,
|
||||
type PortConnectivity,
|
||||
pauseSceneHistory,
|
||||
resolveConnectivityUpdates,
|
||||
resumeSceneHistory,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
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 { type Group, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
||||
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from './ports'
|
||||
import { HandleCube, MoveChevron } from './selection-handles'
|
||||
|
||||
type RefrigerantLineKind = 'lineset' | 'liquid-line'
|
||||
type RefrigerantLineNode = LinesetNode | LiquidLineNode
|
||||
type Point = [number, number, number]
|
||||
type DragKind =
|
||||
| { axis: 'y'; along?: boolean }
|
||||
| { axis: 'horizontal'; dir: [number, number]; along: boolean }
|
||||
type EndpointArrow = {
|
||||
key: string
|
||||
index: number
|
||||
kind: DragKind
|
||||
position: Point
|
||||
rotationY: number
|
||||
vertical?: 'up' | 'down'
|
||||
cursor: Cursor
|
||||
}
|
||||
|
||||
const PORT_SNAP_RADIUS_M = 0.4
|
||||
const ARROW_GAP = 0.28
|
||||
const ARROW_MIN_OFFSET = 0.4
|
||||
const INCHES_TO_METERS = 0.0254
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
function lineRadiusM(line: RefrigerantLineNode): number {
|
||||
if (line.type === 'lineset') {
|
||||
return (Math.max(line.suctionDiameter, line.liquidDiameter) * INCHES_TO_METERS) / 2
|
||||
}
|
||||
return (line.diameter * INCHES_TO_METERS) / 2
|
||||
}
|
||||
|
||||
function selectedLineOfKind(
|
||||
kind: RefrigerantLineKind,
|
||||
id: AnyNodeId | undefined,
|
||||
): RefrigerantLineNode | null {
|
||||
if (!id) return null
|
||||
const node = useScene.getState().nodes[id]
|
||||
if (kind === 'lineset' && node?.type === 'lineset') return node as LinesetNode
|
||||
if (kind === 'liquid-line' && node?.type === 'liquid-line') return node as LiquidLineNode
|
||||
return null
|
||||
}
|
||||
|
||||
export function createRefrigerantLineSelectionAffordance(kind: RefrigerantLineKind) {
|
||||
const RefrigerantLineSelectionAffordance = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = selectedIds.length === 1 ? (selectedIds[0] as AnyNodeId) : undefined
|
||||
const line = useScene(() => selectedLineOfKind(kind, selectedId))
|
||||
|
||||
const lineId = line?.id ?? null
|
||||
const [target, setTarget] = useState<Object3D | null>(null)
|
||||
useEffect(() => {
|
||||
if (!lineId) {
|
||||
setTarget(null)
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(lineId as AnyNodeId) ?? null
|
||||
setTarget((cur) => (cur === next ? cur : next))
|
||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
resolve()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [lineId])
|
||||
|
||||
if (!line || !target) return null
|
||||
const mount = target.parent ?? target
|
||||
return createPortal(
|
||||
<RefrigerantLineEndpointHandles line={line} target={target} />,
|
||||
mount,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
|
||||
return RefrigerantLineSelectionAffordance
|
||||
}
|
||||
|
||||
function RefrigerantLineEndpointHandles({
|
||||
line,
|
||||
target,
|
||||
}: {
|
||||
line: RefrigerantLineNode
|
||||
target: Object3D
|
||||
}) {
|
||||
const { camera, gl } = useThree()
|
||||
const outerRef = useRef<Group>(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<number | null>(null)
|
||||
const [openCluster, setOpenCluster] = useState<number | null>(null)
|
||||
const toggleCluster = (index: number) => setOpenCluster((cur) => (cur === index ? null : index))
|
||||
const dragRef = useRef<{
|
||||
index: number
|
||||
initialPath: Point[]
|
||||
current: Point
|
||||
cleanup: () => void
|
||||
connectivity: PortConnectivity | null
|
||||
detached: boolean
|
||||
} | null>(null)
|
||||
|
||||
const followUpdates = (
|
||||
connectivity: PortConnectivity | null,
|
||||
path: Point[],
|
||||
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
||||
if (!connectivity) return []
|
||||
const preview = { ...(line as unknown as Record<string, unknown>), path } as AnyNode
|
||||
return resolveConnectivityUpdates(connectivity, preview).filter(
|
||||
(u) => useScene.getState().nodes[u.id],
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const intersectVerticalY = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
anchorWorld: Vector3,
|
||||
): number | 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 ? toLocal(hit)[1] : null
|
||||
}
|
||||
|
||||
const swingHorizontal = (event: PointerEvent, pivot: Point, startPoint: Point): Point | null => {
|
||||
const r = Math.hypot(
|
||||
startPoint[0] - pivot[0],
|
||||
startPoint[1] - pivot[1],
|
||||
startPoint[2] - pivot[2],
|
||||
)
|
||||
if (r < 1e-6) return null
|
||||
const verticalN = (startPoint[1] - pivot[1]) / r
|
||||
const horizN = Math.sqrt(Math.max(0, 1 - verticalN * verticalN))
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(pivot))
|
||||
const hit = intersect(event.clientX, event.clientY, plane)
|
||||
if (!hit) return null
|
||||
const local = toLocal(hit)
|
||||
const bx = local[0] - pivot[0]
|
||||
const bz = local[2] - pivot[2]
|
||||
const blen = Math.hypot(bx, bz)
|
||||
if (blen < 1e-6) return null
|
||||
return [(bx / blen) * horizN, verticalN, (bz / blen) * horizN]
|
||||
}
|
||||
|
||||
const swingVertical = (event: PointerEvent, pivot: Point, startPoint: Point): Point | null => {
|
||||
let hx = startPoint[0] - pivot[0]
|
||||
let hz = startPoint[2] - pivot[2]
|
||||
let hlen = Math.hypot(hx, hz)
|
||||
if (hlen < 1e-6) {
|
||||
const forward = camera.getWorldDirection(new Vector3())
|
||||
hx = forward.x
|
||||
hz = forward.z
|
||||
hlen = Math.hypot(hx, hz)
|
||||
if (hlen < 1e-6) {
|
||||
hx = 0
|
||||
hz = 1
|
||||
hlen = 1
|
||||
}
|
||||
}
|
||||
const headingWorld = new Vector3(hx / hlen, 0, hz / hlen)
|
||||
const normal = new Vector3().crossVectors(UP, headingWorld).normalize()
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, toWorld(pivot))
|
||||
const hit = intersect(event.clientX, event.clientY, plane)
|
||||
if (!hit) return null
|
||||
const local = toLocal(hit)
|
||||
const ax = local[0] - pivot[0]
|
||||
const ay = local[1] - pivot[1]
|
||||
const az = local[2] - pivot[2]
|
||||
const len = Math.hypot(ax, ay, az)
|
||||
if (len < 1e-6) return null
|
||||
return [ax / len, ay / len, az / len]
|
||||
}
|
||||
|
||||
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
|
||||
const toLocal = (world: Vector3): Point => {
|
||||
const local = target.worldToLocal(world.clone())
|
||||
return [local.x, local.y, local.z]
|
||||
}
|
||||
|
||||
const onHandleDown = (index: number, kind: DragKind) => (e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const initialPath = line.path.map((p) => [...p] as Point)
|
||||
const startPoint = initialPath[index]!
|
||||
const connectivity = analyzePortConnectivity(line as AnyNode, useScene.getState().nodes)
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
document.body.style.cursor = kind.axis === 'y' ? 'ns-resize' : 'grabbing'
|
||||
setDraggingIndex(index)
|
||||
|
||||
const isEndpoint = index === 0 || index === initialPath.length - 1
|
||||
const swings = kind.axis === 'y' ? kind.along !== true : !kind.along
|
||||
const neighborIndex = index === 0 ? 1 : index === initialPath.length - 1 ? index - 1 : null
|
||||
const pivot = neighborIndex !== null ? initialPath[neighborIndex]! : null
|
||||
const radius = pivot
|
||||
? Math.hypot(startPoint[0] - pivot[0], startPoint[1] - pivot[1], startPoint[2] - pivot[2])
|
||||
: 0
|
||||
const canSwing = swings && isEndpoint && pivot !== null && radius > 1e-6
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
||||
const detached = event.altKey
|
||||
let next: Point | null = null
|
||||
if (canSwing && pivot) {
|
||||
const aim =
|
||||
kind.axis === 'y'
|
||||
? swingVertical(event, pivot, startPoint)
|
||||
: swingHorizontal(event, pivot, startPoint)
|
||||
if (aim) {
|
||||
next = [
|
||||
snap(pivot[0] + aim[0] * radius, step),
|
||||
Math.max(0, snap(pivot[1] + aim[1] * radius, step)),
|
||||
snap(pivot[2] + aim[2] * radius, step),
|
||||
]
|
||||
}
|
||||
} else if (kind.axis === 'y') {
|
||||
const y = intersectVerticalY(event.clientX, event.clientY, toWorld(startPoint))
|
||||
if (y !== null) next = [startPoint[0], Math.max(0, snap(y, step)), startPoint[2]]
|
||||
} else {
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(startPoint))
|
||||
const hit = intersect(event.clientX, event.clientY, plane)
|
||||
if (hit) {
|
||||
const local = toLocal(hit)
|
||||
const [dx, dz] = kind.dir
|
||||
const t = snap((local[0] - startPoint[0]) * dx + (local[2] - startPoint[2]) * dz, step)
|
||||
next = [startPoint[0] + t * dx, startPoint[1], startPoint[2] + t * dz]
|
||||
}
|
||||
}
|
||||
if (!next) return
|
||||
if (isEndpoint) {
|
||||
const port = findNearestPortXZ(
|
||||
[next[0], next[1], next[2]],
|
||||
collectScenePorts({ excludeNodeId: line.id, systems: REFRIGERANT_PORT_SYSTEMS }),
|
||||
PORT_SNAP_RADIUS_M,
|
||||
)
|
||||
if (port) next = [port.position[0], port.position[1], port.position[2]]
|
||||
}
|
||||
if (next[0] === drag.current[0] && next[1] === drag.current[1] && next[2] === drag.current[2])
|
||||
return
|
||||
drag.current = next
|
||||
drag.detached = detached
|
||||
if (step > 0) triggerSFX('sfx:grid-snap')
|
||||
const path = line.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([
|
||||
{ id: line.id as AnyNodeId, data: { path } as Partial<AnyNode> },
|
||||
...(detached ? [] : followUpdates(drag.connectivity, path)),
|
||||
])
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
swallowNextClick()
|
||||
drag.cleanup()
|
||||
dragRef.current = null
|
||||
setDraggingIndex(null)
|
||||
const detached = drag.detached
|
||||
const finalPath = drag.initialPath.map((p, i) =>
|
||||
i === drag.index ? drag.current : p,
|
||||
) as Point[]
|
||||
const revert = detached
|
||||
? []
|
||||
: (drag.connectivity?.connections ?? []).map((conn) =>
|
||||
conn.kind === 'rigid-node'
|
||||
? { id: conn.nodeId, data: { position: conn.startPosition } as Partial<AnyNode> }
|
||||
: { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> },
|
||||
)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([
|
||||
{ id: line.id as AnyNodeId, data: { path: drag.initialPath } as Partial<AnyNode> },
|
||||
...revert.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: line.id as AnyNodeId, data: { path: finalPath } as Partial<AnyNode> },
|
||||
...(detached ? [] : followUpdates(drag.connectivity, finalPath)),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
dragRef.current = {
|
||||
index,
|
||||
initialPath,
|
||||
current: startPoint,
|
||||
cleanup,
|
||||
connectivity,
|
||||
detached: false,
|
||||
}
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
const endpointArrows = useMemo(() => getEndpointArrows(line), [line])
|
||||
const endpointIndices = useMemo(() => {
|
||||
if (line.path.length < 2) return []
|
||||
const last = line.path.length - 1
|
||||
return last === 0 ? [0] : [0, last]
|
||||
}, [line.path.length])
|
||||
|
||||
return (
|
||||
<group ref={outerRef}>
|
||||
{draggingIndex === null &&
|
||||
endpointIndices.map((index) => {
|
||||
const point = line.path[index]!
|
||||
return (
|
||||
<group key={`line-end-${index}`}>
|
||||
<HandleCube
|
||||
active={openCluster === index}
|
||||
onClick={() => toggleCluster(index)}
|
||||
position={point as Point}
|
||||
rotationY={vertexYaw(line, index)}
|
||||
/>
|
||||
{openCluster === index &&
|
||||
endpointArrows
|
||||
.filter((a) => a.index === index)
|
||||
.map((a) => (
|
||||
<MoveChevron
|
||||
cursor={a.cursor}
|
||||
key={a.key}
|
||||
onPointerDown={onHandleDown(a.index, a.kind)}
|
||||
position={a.position}
|
||||
rotationY={a.rotationY}
|
||||
vertical={a.vertical}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
})}
|
||||
{draggingIndex !== null &&
|
||||
line.path[draggingIndex] &&
|
||||
(() => {
|
||||
const point = line.path[draggingIndex]!
|
||||
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
|
||||
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
|
||||
const axes = ['x', 'y', 'z'] as const
|
||||
const primary = axes.reduce((best, axis, i) =>
|
||||
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
|
||||
)
|
||||
return (
|
||||
<Html
|
||||
center
|
||||
position={[point[0], point[1] + 0.35, point[2]]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<DimensionPill
|
||||
parts={axes.map((axis, i) => ({
|
||||
key: axis,
|
||||
prefix: axis.toUpperCase(),
|
||||
value: deltas[i]!,
|
||||
signed: true,
|
||||
}))}
|
||||
primary={primary}
|
||||
unit={unit}
|
||||
/>
|
||||
</Html>
|
||||
)
|
||||
})()}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function getEndpointArrows(line: RefrigerantLineNode): EndpointArrow[] {
|
||||
const arrows: EndpointArrow[] = []
|
||||
const base = Math.max(lineRadiusM(line) + ARROW_GAP, ARROW_MIN_OFFSET)
|
||||
const last = line.path.length - 1
|
||||
if (last < 1) return arrows
|
||||
for (const i of [0, last]) {
|
||||
const p = line.path[i]!
|
||||
const tangentXZ = vertexTangentXZ(line, i)
|
||||
const verticalTangentY = tangentXZ ? null : vertexTangentY(line, i)
|
||||
const t = tangentXZ ?? ([1, 0] as [number, number])
|
||||
const runYaw = Math.atan2(-t[1], t[0])
|
||||
const dirs: { dir: [number, number]; along: boolean }[] = tangentXZ
|
||||
? [
|
||||
{ dir: [t[0], t[1]], along: true },
|
||||
{ dir: [-t[0], -t[1]], along: true },
|
||||
{ dir: [-t[1], t[0]], along: false },
|
||||
{ dir: [t[1], -t[0]], along: false },
|
||||
]
|
||||
: [
|
||||
{ dir: [1, 0], along: false },
|
||||
{ dir: [-1, 0], along: false },
|
||||
{ dir: [0, 1], along: false },
|
||||
{ dir: [0, -1], along: false },
|
||||
]
|
||||
const inward: [number, number] | null =
|
||||
tangentXZ && i === 0 ? [t[0], t[1]] : tangentXZ && i === last ? [-t[0], -t[1]] : null
|
||||
for (const { dir, along } of dirs) {
|
||||
const [dx, dz] = dir
|
||||
if (inward && dx * inward[0] + dz * inward[1] > 0.999) continue
|
||||
arrows.push({
|
||||
key: `pt${i}-${dx.toFixed(3)}:${dz.toFixed(3)}`,
|
||||
index: i,
|
||||
kind: { axis: 'horizontal', dir: [dx, dz], along },
|
||||
position: [p[0] + dx * base, p[1], p[2] + dz * base],
|
||||
rotationY: Math.atan2(-dz, dx),
|
||||
cursor: 'grab',
|
||||
})
|
||||
}
|
||||
const inwardY =
|
||||
verticalTangentY && i === 0
|
||||
? verticalTangentY
|
||||
: verticalTangentY && i === last
|
||||
? -verticalTangentY
|
||||
: null
|
||||
for (const sign of [1, -1] as const) {
|
||||
if (inwardY === sign) continue
|
||||
arrows.push({
|
||||
key: `pt${i}-${sign > 0 ? 'up' : 'down'}`,
|
||||
index: i,
|
||||
kind: { axis: 'y', along: verticalTangentY !== null },
|
||||
position: [p[0], p[1] + sign * base, p[2]],
|
||||
rotationY: runYaw,
|
||||
vertical: sign > 0 ? 'up' : 'down',
|
||||
cursor: 'ns-resize',
|
||||
})
|
||||
}
|
||||
}
|
||||
return arrows
|
||||
}
|
||||
|
||||
function vertexTangentXZ(line: RefrigerantLineNode, i: number): [number, number] | null {
|
||||
const path = line.path
|
||||
const last = path.length - 1
|
||||
if (last < 1) return null
|
||||
const neighbor = i === 0 ? path[1]! : path[last - 1]!
|
||||
const point = path[i]!
|
||||
const dx = i === 0 ? neighbor[0] - point[0] : point[0] - neighbor[0]
|
||||
const dz = i === 0 ? neighbor[2] - point[2] : point[2] - neighbor[2]
|
||||
const len = Math.hypot(dx, dz)
|
||||
return len < 1e-6 ? null : [dx / len, dz / len]
|
||||
}
|
||||
|
||||
function vertexTangentY(line: RefrigerantLineNode, i: number): 1 | -1 | null {
|
||||
const path = line.path
|
||||
const last = path.length - 1
|
||||
if (last < 1) return null
|
||||
const neighbor = i === 0 ? path[1]! : path[last - 1]!
|
||||
const point = path[i]!
|
||||
const dx = i === 0 ? neighbor[0] - point[0] : point[0] - neighbor[0]
|
||||
const dy = i === 0 ? neighbor[1] - point[1] : point[1] - neighbor[1]
|
||||
const dz = i === 0 ? neighbor[2] - point[2] : point[2] - neighbor[2]
|
||||
if (Math.hypot(dx, dz) > 1e-6 || Math.abs(dy) < 1e-6) return null
|
||||
return dy > 0 ? 1 : -1
|
||||
}
|
||||
|
||||
function vertexYaw(line: RefrigerantLineNode, i: number): number {
|
||||
const t = vertexTangentXZ(line, i)
|
||||
return t ? Math.atan2(-t[1], t[0]) : 0
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export type RelativeRoofDragTarget = {
|
||||
hit: RoofSegmentHit
|
||||
}
|
||||
|
||||
const ROOF_DRAG_SNAP_STEP_M = 0.05
|
||||
|
||||
type RelativeRoofDragState = {
|
||||
segmentId: string
|
||||
anchor: [number, number]
|
||||
@@ -114,3 +116,20 @@ export function createRelativeRoofDrag(original: {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function snapRelativeRoofDragTarget(
|
||||
target: RelativeRoofDragTarget,
|
||||
bypass = false,
|
||||
): RelativeRoofDragTarget {
|
||||
if (bypass) return target
|
||||
const localX = Math.round(target.localX / ROOF_DRAG_SNAP_STEP_M) * ROOF_DRAG_SNAP_STEP_M
|
||||
const localZ = Math.round(target.localZ / ROOF_DRAG_SNAP_STEP_M) * ROOF_DRAG_SNAP_STEP_M
|
||||
const surfaceOffsetY = target.localY - getSurfaceY(target.localX, target.localZ, target.segment)
|
||||
const localY = getSurfaceY(localX, localZ, target.segment) + surfaceOffsetY
|
||||
return {
|
||||
...target,
|
||||
localX,
|
||||
localY,
|
||||
localZ,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { type AnyNode, type RoofSegmentNode, useScene } from '@pascal-app/core'
|
||||
import { getRoofSurfaceFaceBoundsAt } from './roof-surface'
|
||||
|
||||
mock.module('@pascal-app/editor', () => ({
|
||||
useOpeningGuides: {
|
||||
getState: () => ({
|
||||
clear: () => undefined,
|
||||
set: () => undefined,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('@pascal-app/viewer', () => ({
|
||||
Brush: class {},
|
||||
SUBTRACTION: 0,
|
||||
csgEvaluator: {
|
||||
evaluate: () => ({ geometry: { dispose: () => undefined } }),
|
||||
},
|
||||
csgGeometry: () => ({
|
||||
clone: () => ({
|
||||
addGroup: () => undefined,
|
||||
clearGroups: () => undefined,
|
||||
getIndex: () => null,
|
||||
translate: () => undefined,
|
||||
}),
|
||||
}),
|
||||
prepareBrushForCSG: () => undefined,
|
||||
useViewer: {
|
||||
getState: () => ({
|
||||
selection: {},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../skylight/frame-csg', () => ({
|
||||
buildFrameGeometry: () => null,
|
||||
}))
|
||||
|
||||
const fixtureSegment = (overrides?: Partial<RoofSegmentNode>): RoofSegmentNode =>
|
||||
({
|
||||
object: 'node',
|
||||
id: 'rseg_fixture',
|
||||
type: 'roof-segment',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
roofType: 'gable',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
wallHeight: 2.5,
|
||||
pitch: (Math.atan2(2, 3) * 180) / Math.PI,
|
||||
wallThickness: 0.1,
|
||||
deckThickness: 0.1,
|
||||
overhang: 0.3,
|
||||
shingleThickness: 0.05,
|
||||
children: [],
|
||||
...overrides,
|
||||
}) as RoofSegmentNode
|
||||
|
||||
const roofItem = (
|
||||
id: string,
|
||||
position: [number, number, number],
|
||||
overrides?: Record<string, unknown>,
|
||||
): AnyNode =>
|
||||
({
|
||||
object: 'node',
|
||||
id,
|
||||
type: 'box-vent',
|
||||
parentId: 'rseg_fixture',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position,
|
||||
rotation: 0,
|
||||
width: 1,
|
||||
depth: 1,
|
||||
height: 0.2,
|
||||
style: 'box',
|
||||
...overrides,
|
||||
}) as AnyNode
|
||||
|
||||
const dormerItem = (id: string, position: [number, number, number]): AnyNode =>
|
||||
roofItem(id, position, {
|
||||
type: 'dormer',
|
||||
width: 1.2,
|
||||
depth: 1.4,
|
||||
height: 0.4,
|
||||
roofType: 'gable',
|
||||
roofHeight: 0.5,
|
||||
wallSkirtHeight: 1.2,
|
||||
})
|
||||
|
||||
const chimneyItem = (id: string, position: [number, number, number]): AnyNode =>
|
||||
roofItem(id, position, {
|
||||
type: 'chimney',
|
||||
bodyShape: 'square',
|
||||
bodyHollowDepth: 0.6,
|
||||
bodyHollowMargin: 0.08,
|
||||
width: 0.6,
|
||||
depth: 0.6,
|
||||
heightAboveRidge: 1,
|
||||
cutoutOffset: 0,
|
||||
cornerBevel: 0,
|
||||
cap: true,
|
||||
capShape: 'flat',
|
||||
capOverhang: 0.04,
|
||||
capThickness: 0.08,
|
||||
flueCount: 1,
|
||||
flueShape: 'round',
|
||||
flueHeight: 0.3,
|
||||
flueDiameter: 0.22,
|
||||
flueSpacing: 1,
|
||||
flueWallThickness: 0.02,
|
||||
shoulderStyle: 'none',
|
||||
shoulderHeight: 0.5,
|
||||
shoulderExtent: 0.1,
|
||||
bandStyle: 'none',
|
||||
bandHeight: 0.1,
|
||||
bandExtent: 0.04,
|
||||
bandOffset: 0.4,
|
||||
cricketStyle: 'none',
|
||||
cricketLength: 0.6,
|
||||
cricketHeight: 0.4,
|
||||
cricketSide: 'front',
|
||||
panelStyle: 'none',
|
||||
panelDepth: 0.03,
|
||||
panelHeight: 0.8,
|
||||
panelOffsetTop: 0.15,
|
||||
panelMargin: 0.1,
|
||||
})
|
||||
|
||||
const supportedRoofSibling = (
|
||||
type: string,
|
||||
id: string,
|
||||
position: [number, number, number],
|
||||
): AnyNode => {
|
||||
switch (type) {
|
||||
case 'dormer':
|
||||
return dormerItem(id, position)
|
||||
case 'chimney':
|
||||
return chimneyItem(id, position)
|
||||
case 'solar-panel':
|
||||
return roofItem(id, position, {
|
||||
type,
|
||||
columns: 2,
|
||||
rows: 1,
|
||||
panelWidth: 0.8,
|
||||
panelHeight: 1.2,
|
||||
gapX: 0.05,
|
||||
gapY: 0.05,
|
||||
mountingType: 'flush',
|
||||
tiltAngle: 15,
|
||||
frameThickness: 0.04,
|
||||
frameDepth: 0.04,
|
||||
standoffHeight: 0.1,
|
||||
})
|
||||
case 'ridge-vent':
|
||||
return roofItem(id, position, { type, length: 1.2, width: 0.25, height: 0.1 })
|
||||
case 'gutter':
|
||||
return roofItem(id, position, {
|
||||
type,
|
||||
length: 1.2,
|
||||
size: 0.15,
|
||||
thickness: 0.006,
|
||||
profile: 'k-style',
|
||||
endCapLeft: true,
|
||||
endCapRight: true,
|
||||
hangerStyle: 'strap',
|
||||
hangerSpacing: 0.6,
|
||||
outlets: [],
|
||||
})
|
||||
case 'turbine-vent':
|
||||
return roofItem(id, position, { type, diameter: 0.5, height: 0.7 })
|
||||
case 'skylight':
|
||||
return roofItem(id, position, {
|
||||
type,
|
||||
width: 0.8,
|
||||
height: 1.1,
|
||||
frameDepth: 0.05,
|
||||
frameThickness: 0.08,
|
||||
glassThickness: 0.02,
|
||||
curb: false,
|
||||
curbHeight: 0,
|
||||
})
|
||||
case 'cupola':
|
||||
return roofItem(id, position, { type, width: 0.8, depth: 0.8, height: 1 })
|
||||
case 'eyebrow-vent':
|
||||
return roofItem(id, position, { type, width: 0.8, depth: 0.4, height: 0.25 })
|
||||
default:
|
||||
return roofItem(id, position, { type })
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
|
||||
})
|
||||
|
||||
describe('roofSiblingSpacingGuides', () => {
|
||||
test('measures to the nearest aligned roof item bounding-box side', async () => {
|
||||
const { roofFaceKey, roofGuideBounds, roofSiblingSpacingGuides } = await import(
|
||||
'./roof-surface-placement-guides'
|
||||
)
|
||||
const segment = fixtureSegment({ children: ['near', 'far'] as never })
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
near: roofItem('near', [2, 0, 1]),
|
||||
far: roofItem('far', [4, 0, 1]),
|
||||
},
|
||||
} as never)
|
||||
|
||||
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
|
||||
const guides = roofSiblingSpacingGuides({
|
||||
segment,
|
||||
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
|
||||
faceKey,
|
||||
dimension: (id, from, to) => ({
|
||||
id,
|
||||
from,
|
||||
to,
|
||||
value: Math.hypot(to[0] - from[0], to[1] - from[1]),
|
||||
}),
|
||||
})
|
||||
|
||||
expect(guides).toEqual([
|
||||
{
|
||||
id: 'roof-sibling:right',
|
||||
from: [0.5, 1],
|
||||
to: [1.5, 1],
|
||||
value: 1,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('marks the roof-edge side as blocked when an aligned item is between them', async () => {
|
||||
const { roofFaceKey, roofGuideBounds, roofSiblingSpacing } = await import(
|
||||
'./roof-surface-placement-guides'
|
||||
)
|
||||
const segment = fixtureSegment({ children: ['left'] as never })
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
left: roofItem('left', [-3, 0, 1]),
|
||||
},
|
||||
} as never)
|
||||
|
||||
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
|
||||
const spacing = roofSiblingSpacing({
|
||||
segment,
|
||||
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
|
||||
faceKey,
|
||||
dimension: (id, from, to) => ({ id, from, to }),
|
||||
})
|
||||
|
||||
expect(spacing.blockedSides).toEqual({
|
||||
left: true,
|
||||
right: false,
|
||||
bottom: false,
|
||||
top: false,
|
||||
})
|
||||
expect(spacing.guides).toEqual([
|
||||
{
|
||||
id: 'roof-sibling:left',
|
||||
from: [-2.5, 1],
|
||||
to: [-0.5, 1],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('measures to a roof item whose bounding box crosses the guide lane', async () => {
|
||||
const { roofFaceKey, roofGuideBounds, roofSiblingSpacingGuides } = await import(
|
||||
'./roof-surface-placement-guides'
|
||||
)
|
||||
const segment = fixtureSegment({ children: ['offset'] as never })
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
offset: roofItem('offset', [2, 0, 1.2]),
|
||||
},
|
||||
} as never)
|
||||
|
||||
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
|
||||
const guides = roofSiblingSpacingGuides({
|
||||
segment,
|
||||
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
|
||||
faceKey,
|
||||
dimension: (id, from, to) => ({ id, from, to }),
|
||||
})
|
||||
|
||||
expect(guides).toEqual([
|
||||
{
|
||||
id: 'roof-sibling:right',
|
||||
from: [0.5, 1],
|
||||
to: [1.5, 1],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('adds a red alignment guide when roof item centers align on a lane', async () => {
|
||||
const { roofFaceKey, roofGuideBounds, roofSiblingSpacing } = await import(
|
||||
'./roof-surface-placement-guides'
|
||||
)
|
||||
const segment = fixtureSegment({ children: ['aligned'] as never })
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
aligned: roofItem('aligned', [2, 0, 1]),
|
||||
},
|
||||
} as never)
|
||||
|
||||
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
|
||||
const spacing = roofSiblingSpacing({
|
||||
segment,
|
||||
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
|
||||
faceKey,
|
||||
dimension: (id, from, to) => ({ kind: 'dimension', id, from, to }),
|
||||
alignLine: (id, from, to) => ({ kind: 'align-line', id, from, to }),
|
||||
})
|
||||
|
||||
expect(spacing.guides).toContainEqual({
|
||||
kind: 'align-line',
|
||||
id: 'roof-align:z',
|
||||
from: [-0.5, 1],
|
||||
to: [2.5, 1],
|
||||
})
|
||||
})
|
||||
|
||||
test('adds an alignment guide when roof item bounding-box edges align', async () => {
|
||||
const { roofFaceKey, roofGuideBounds, roofSiblingSpacing } = await import(
|
||||
'./roof-surface-placement-guides'
|
||||
)
|
||||
const segment = fixtureSegment({ children: ['aligned'] as never })
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
aligned: roofItem('aligned', [2, 0, 1]),
|
||||
},
|
||||
} as never)
|
||||
|
||||
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
|
||||
const spacing = roofSiblingSpacing({
|
||||
segment,
|
||||
movingBounds: roofGuideBounds([0, 0, 2], { width: 1, depth: 1 }),
|
||||
faceKey,
|
||||
dimension: (id, from, to) => ({ kind: 'dimension', id, from, to }),
|
||||
alignLine: (id, from, to) => ({ kind: 'align-line', id, from, to }),
|
||||
})
|
||||
|
||||
expect(spacing.guides).toContainEqual({
|
||||
kind: 'align-line',
|
||||
id: 'roof-align:z',
|
||||
from: [-0.5, 1.5],
|
||||
to: [2.5, 1.5],
|
||||
})
|
||||
})
|
||||
|
||||
test('snaps a dragged roof item onto a nearby sibling bounding-box alignment', async () => {
|
||||
const { snapRoofSurfaceNodeTarget } = await import('./roof-surface-placement-guides')
|
||||
const segment = fixtureSegment({ children: ['aligned'] as never })
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
aligned: roofItem('aligned', [2, 0, 1]),
|
||||
},
|
||||
} as never)
|
||||
|
||||
const snapped = snapRoofSurfaceNodeTarget({
|
||||
target: {
|
||||
segment,
|
||||
localX: 0,
|
||||
localY: 0,
|
||||
localZ: 2.04,
|
||||
hit: {} as never,
|
||||
},
|
||||
node: roofItem('moving', [0, 0, 0]),
|
||||
})
|
||||
|
||||
expect(snapped.localZ).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
test('adds equal-spacing badges for a roof item between evenly spaced siblings', async () => {
|
||||
const { roofFaceKey, roofGuideBounds, roofSiblingSpacing } = await import(
|
||||
'./roof-surface-placement-guides'
|
||||
)
|
||||
const segment = fixtureSegment({ children: ['left', 'right'] as never })
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
left: roofItem('left', [-2, 0, 1]),
|
||||
right: roofItem('right', [2, 0, 1]),
|
||||
},
|
||||
} as never)
|
||||
|
||||
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
|
||||
const spacing = roofSiblingSpacing({
|
||||
segment,
|
||||
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
|
||||
faceKey,
|
||||
dimension: (id, from, to) => ({ kind: 'dimension', id, from, to }),
|
||||
badge: (id, at, value) => ({ kind: 'badge', id, at, value }),
|
||||
})
|
||||
|
||||
expect(spacing.guides).toContainEqual({
|
||||
kind: 'badge',
|
||||
id: 'roof-spacing:x:0',
|
||||
at: [-1, 1],
|
||||
value: 1,
|
||||
})
|
||||
expect(spacing.guides).toContainEqual({
|
||||
kind: 'badge',
|
||||
id: 'roof-spacing:x:1',
|
||||
at: [1, 1],
|
||||
value: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('adds equal-spacing badges for mixed roof item types on the same lane', async () => {
|
||||
const { roofFaceKey, roofGuideBounds, roofSiblingSpacing, roofSurfaceFootprintFromNode } =
|
||||
await import('./roof-surface-placement-guides')
|
||||
const segment = fixtureSegment({ children: ['chimney', 'vent'] as never })
|
||||
const chimney = chimneyItem('chimney', [0, 0, 1])
|
||||
const vent = roofItem('vent', [0, 0, 1], { type: 'turbine-vent', diameter: 0.6, height: 0.7 })
|
||||
const movingFootprint = { width: 1.4, depth: 1 }
|
||||
const movingBounds = roofGuideBounds([0, 0, 1], movingFootprint)
|
||||
const gap = 0.8
|
||||
const chimneyWidth = roofSurfaceFootprintFromNode(chimney, { segment }).width
|
||||
const ventWidth = roofSurfaceFootprintFromNode(vent, { segment }).width
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
chimney: { ...chimney, position: [movingBounds.minX - gap - chimneyWidth / 2, 0, 1] },
|
||||
vent: { ...vent, position: [movingBounds.maxX + gap + ventWidth / 2, 0, 1] },
|
||||
},
|
||||
} as never)
|
||||
|
||||
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
|
||||
const spacing = roofSiblingSpacing({
|
||||
segment,
|
||||
movingBounds,
|
||||
faceKey,
|
||||
dimension: (id, from, to) => ({ kind: 'dimension', id, from, to }),
|
||||
badge: (id, at, value) => ({ kind: 'badge', id, at, value }),
|
||||
})
|
||||
|
||||
expect(spacing.guides).toContainEqual({
|
||||
kind: 'badge',
|
||||
id: 'roof-spacing:x:0',
|
||||
at: [movingBounds.minX - gap / 2, 1],
|
||||
value: 0.8,
|
||||
})
|
||||
expect(spacing.guides).toContainEqual({
|
||||
kind: 'badge',
|
||||
id: 'roof-spacing:x:1',
|
||||
at: [movingBounds.maxX + gap / 2, 1],
|
||||
value: 0.8,
|
||||
})
|
||||
})
|
||||
|
||||
test('does not measure to a roof item outside the guide lane bounding box', async () => {
|
||||
const { roofFaceKey, roofGuideBounds, roofSiblingSpacingGuides } = await import(
|
||||
'./roof-surface-placement-guides'
|
||||
)
|
||||
const segment = fixtureSegment({ children: ['offset'] as never })
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
offset: roofItem('offset', [2, 0, 2]),
|
||||
},
|
||||
} as never)
|
||||
|
||||
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
|
||||
const guides = roofSiblingSpacingGuides({
|
||||
segment,
|
||||
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
|
||||
faceKey,
|
||||
dimension: (id, from, to) => ({ id, from, to }),
|
||||
})
|
||||
|
||||
expect(guides).toEqual([])
|
||||
})
|
||||
|
||||
test.each([
|
||||
['chimney moving next to dormer', dormerItem('sibling', [2, 0, 1])],
|
||||
['dormer moving next to chimney', chimneyItem('sibling', [2, 0, 1])],
|
||||
['dormer moving next to dormer', dormerItem('sibling', [2, 0, 1])],
|
||||
['dormer moving next to vent', roofItem('sibling', [2, 0, 1])],
|
||||
])('measures mixed roof item spacing: %s', async (_label, sibling) => {
|
||||
const { roofFaceKey, roofGuideBounds, roofSiblingSpacingGuides } = await import(
|
||||
'./roof-surface-placement-guides'
|
||||
)
|
||||
const segment = fixtureSegment({ children: ['sibling'] as never })
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
sibling,
|
||||
},
|
||||
} as never)
|
||||
|
||||
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
|
||||
const guides = roofSiblingSpacingGuides({
|
||||
segment,
|
||||
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
|
||||
faceKey,
|
||||
dimension: (id, from, to) => ({ id, from, to }),
|
||||
})
|
||||
|
||||
expect(guides).toHaveLength(1)
|
||||
expect(guides[0]?.id).toBe('roof-sibling:right')
|
||||
})
|
||||
|
||||
test.each([
|
||||
'box-vent',
|
||||
'turbine-vent',
|
||||
'eyebrow-vent',
|
||||
'solar-panel',
|
||||
'skylight',
|
||||
'cupola',
|
||||
'chimney',
|
||||
'ridge-vent',
|
||||
'gutter',
|
||||
'dormer',
|
||||
])('recognizes %s as a roof spacing sibling', async (type) => {
|
||||
const { roofFaceKey, roofGuideBounds, roofSiblingSpacingGuides } = await import(
|
||||
'./roof-surface-placement-guides'
|
||||
)
|
||||
const sibling = supportedRoofSibling(type, 'sibling', [2, 0, 1])
|
||||
const segment = fixtureSegment({ children: ['sibling'] as never })
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
sibling,
|
||||
},
|
||||
} as never)
|
||||
|
||||
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
|
||||
const guides = roofSiblingSpacingGuides({
|
||||
segment,
|
||||
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
|
||||
faceKey,
|
||||
dimension: (id, from, to) => ({ id, from, to }),
|
||||
})
|
||||
|
||||
expect(guides).toHaveLength(1)
|
||||
expect(guides[0]?.id).toBe('roof-sibling:right')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,859 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} 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 type { RelativeRoofDragTarget } from './relative-roof-drag'
|
||||
import { getRoofSurfaceFaceBoundsAt, getSurfaceY } from './roof-surface'
|
||||
|
||||
const MIN_DIMENSION_M = 0.02
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
const EQUAL_SPACING_THRESHOLD_M = 0.03
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type RoofGuideBounds = {
|
||||
centerX: number
|
||||
centerZ: number
|
||||
minX: number
|
||||
maxX: number
|
||||
minZ: number
|
||||
maxZ: number
|
||||
}
|
||||
|
||||
type RoofGuideSide = 'left' | 'right' | 'bottom' | 'top'
|
||||
|
||||
type RoofSiblingSpacingResult<T> = {
|
||||
guides: T[]
|
||||
blockedSides: Record<RoofGuideSide, boolean>
|
||||
}
|
||||
|
||||
type RoofAlignmentFeature = 'min' | 'center' | 'max'
|
||||
|
||||
type RoofAlignmentCandidate = {
|
||||
axis: 'x' | 'z'
|
||||
coord: number
|
||||
gap: number
|
||||
from: [number, number]
|
||||
to: [number, number]
|
||||
}
|
||||
|
||||
type RoofEqualSpacingItem = {
|
||||
bounds: RoofGuideBounds
|
||||
moving: boolean
|
||||
}
|
||||
|
||||
type RoofEqualSpacingGap = {
|
||||
value: number
|
||||
from: [number, number]
|
||||
to: [number, number]
|
||||
}
|
||||
|
||||
export function roofSurfaceFootprintFromNode(
|
||||
node: unknown,
|
||||
options?: { segment?: RoofSegmentNode },
|
||||
): RoofSurfaceGuideFootprint {
|
||||
const n = node as Record<string, unknown>
|
||||
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<string, unknown>,
|
||||
segment: RoofSegmentNode | undefined,
|
||||
): Pick<RoofSurfaceGuideFootprint, 'width' | 'depth'> | 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<typeof buildBoxVentGeometry>[0]))
|
||||
break
|
||||
case 'turbine-vent':
|
||||
add(buildTurbineVentGeometry(node as Parameters<typeof buildTurbineVentGeometry>[0]))
|
||||
break
|
||||
case 'eyebrow-vent':
|
||||
add(buildEyebrowVentGeometry(node as Parameters<typeof buildEyebrowVentGeometry>[0]))
|
||||
break
|
||||
case 'solar-panel':
|
||||
add(buildSolarPanelGeometry(node as Parameters<typeof buildSolarPanelGeometry>[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<typeof buildCupolaGeometry>[0]))
|
||||
break
|
||||
case 'chimney':
|
||||
if (segment) {
|
||||
const geo = buildChimneyGeometry(
|
||||
node as Parameters<typeof buildChimneyGeometry>[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<typeof buildRidgeVentGeometry>[0]))
|
||||
break
|
||||
case 'gutter':
|
||||
add(buildGutterGeometry(node as Parameters<typeof buildGutterGeometry>[0]))
|
||||
break
|
||||
case 'dormer':
|
||||
add(buildDormerGhostGeometry(node as Parameters<typeof buildDormerGhostGeometry>[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
|
||||
if (
|
||||
!Number.isFinite(bounds.min.x) ||
|
||||
!Number.isFinite(bounds.max.x) ||
|
||||
!Number.isFinite(bounds.min.z) ||
|
||||
!Number.isFinite(bounds.max.z)
|
||||
) {
|
||||
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<string, unknown>): 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
|
||||
movingId?: string
|
||||
}): void {
|
||||
const { segment, center, footprint, mode = 'side-center', movingId } = args
|
||||
const segObj = sceneRegistry.nodes.get(segment.id as AnyNodeId)
|
||||
if (!segObj) return
|
||||
|
||||
const bounds = roofGuideBounds(center, footprint)
|
||||
const halfW = Math.max(0, footprint.width) / 2
|
||||
const cos = Math.cos(footprint.rotation ?? 0)
|
||||
const sin = Math.sin(footprint.rotation ?? 0)
|
||||
|
||||
const faceBounds = getRoofSurfaceFaceBoundsAt(segment, center[0], center[2])
|
||||
const faceKey = roofFaceKey(faceBounds.polygon)
|
||||
|
||||
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 alignLine = (
|
||||
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: 'align-line',
|
||||
id,
|
||||
from: from3,
|
||||
to: to3,
|
||||
}
|
||||
}
|
||||
const measure = (from: [number, number], to: [number, number]): number => {
|
||||
const from3 = toBuilding(from[0], from[1])
|
||||
const to3 = toBuilding(to[0], to[1])
|
||||
return tmpA.set(...from3).distanceTo(tmpB.set(...to3))
|
||||
}
|
||||
const badge = (id: string, at: [number, number], value: number): OpeningGuide3D | null => {
|
||||
if (value <= MIN_DIMENSION_M) return null
|
||||
return {
|
||||
kind: 'badge',
|
||||
id,
|
||||
at: toBuilding(at[0], at[1]),
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
const guides: OpeningGuide3D[] = []
|
||||
const siblingSpacing =
|
||||
mode === 'linear-edge'
|
||||
? null
|
||||
: roofSiblingSpacing({
|
||||
segment,
|
||||
movingId,
|
||||
movingBounds: bounds,
|
||||
faceKey,
|
||||
dimension,
|
||||
alignLine,
|
||||
badge,
|
||||
measure,
|
||||
})
|
||||
|
||||
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(bounds.centerX - halfW, faceMinX, faceMaxX)
|
||||
const endX = clamp(bounds.centerX + 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(bounds.centerZ - halfW, faceMinZ, faceMaxZ)
|
||||
const endZ = clamp(bounds.centerZ + 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(bounds.minX, faceMinX, faceMaxX)
|
||||
const itemMaxX = clamp(bounds.maxX, faceMinX, faceMaxX)
|
||||
if (!siblingSpacing?.blockedSides.left) {
|
||||
const left = dimension('roof-gap:left', [faceMinX, center[2]], [itemMinX, center[2]])
|
||||
if (left) guides.push(left)
|
||||
}
|
||||
if (!siblingSpacing?.blockedSides.right) {
|
||||
const right = dimension('roof-gap:right', [itemMaxX, center[2]], [faceMaxX, center[2]])
|
||||
if (right) guides.push(right)
|
||||
}
|
||||
}
|
||||
if (zInterval) {
|
||||
const [faceMinZ, faceMaxZ] = zInterval
|
||||
const itemMinZ = clamp(bounds.minZ, faceMinZ, faceMaxZ)
|
||||
const itemMaxZ = clamp(bounds.maxZ, faceMinZ, faceMaxZ)
|
||||
if (!siblingSpacing?.blockedSides.bottom) {
|
||||
const bottom = dimension('roof-gap:bottom', [center[0], faceMinZ], [center[0], itemMinZ])
|
||||
if (bottom) guides.push(bottom)
|
||||
}
|
||||
if (!siblingSpacing?.blockedSides.top) {
|
||||
const top = dimension('roof-gap:top', [center[0], itemMaxZ], [center[0], faceMaxZ])
|
||||
if (top) guides.push(top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (siblingSpacing) guides.push(...siblingSpacing.guides)
|
||||
|
||||
useOpeningGuides.getState().set(guides)
|
||||
}
|
||||
|
||||
export function publishRoofSurfaceNodePlacementGuides(args: {
|
||||
roof: RoofNode
|
||||
segment: RoofSegmentNode
|
||||
center: readonly [number, number, number]
|
||||
node: unknown
|
||||
mode?: RoofSurfaceGuideMode
|
||||
movingId?: string
|
||||
}): void {
|
||||
const movingId =
|
||||
args.movingId ??
|
||||
((args.node as { id?: unknown }).id && typeof (args.node as { id?: unknown }).id === 'string'
|
||||
? (args.node as { id: string }).id
|
||||
: undefined)
|
||||
|
||||
publishRoofSurfacePlacementGuides({
|
||||
roof: args.roof,
|
||||
segment: args.segment,
|
||||
center: args.center,
|
||||
footprint: roofSurfaceFootprintFromNode(args.node, { segment: args.segment }),
|
||||
mode: args.mode,
|
||||
movingId,
|
||||
})
|
||||
}
|
||||
|
||||
export function snapRoofSurfaceNodeTarget(args: {
|
||||
target: RelativeRoofDragTarget
|
||||
node: unknown
|
||||
movingId?: string
|
||||
bypass?: boolean
|
||||
}): RelativeRoofDragTarget {
|
||||
if (args.bypass) return args.target
|
||||
|
||||
const movingId =
|
||||
args.movingId ??
|
||||
((args.node as { id?: unknown }).id && typeof (args.node as { id?: unknown }).id === 'string'
|
||||
? (args.node as { id: string }).id
|
||||
: undefined)
|
||||
const movingBounds = roofGuideBounds(
|
||||
[args.target.localX, args.target.localY, args.target.localZ],
|
||||
roofSurfaceFootprintFromNode(args.node, { segment: args.target.segment }),
|
||||
)
|
||||
const faceKey = roofFaceKey(
|
||||
getRoofSurfaceFaceBoundsAt(args.target.segment, args.target.localX, args.target.localZ).polygon,
|
||||
)
|
||||
const snap = roofAlignmentSnap({
|
||||
segment: args.target.segment,
|
||||
movingId,
|
||||
movingBounds,
|
||||
faceKey,
|
||||
})
|
||||
if (!snap) return args.target
|
||||
|
||||
const localX = args.target.localX + (snap.dx ?? 0)
|
||||
const localZ = args.target.localZ + (snap.dz ?? 0)
|
||||
const surfaceOffsetY =
|
||||
args.target.localY - getSurfaceY(args.target.localX, args.target.localZ, args.target.segment)
|
||||
const localY = getSurfaceY(localX, localZ, args.target.segment) + surfaceOffsetY
|
||||
return {
|
||||
...args.target,
|
||||
localX,
|
||||
localY,
|
||||
localZ,
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRoofSurfacePlacementGuides(): void {
|
||||
useOpeningGuides.getState().clear()
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function roofGuideBounds(
|
||||
center: readonly [number, number, number],
|
||||
footprint: RoofSurfaceGuideFootprint,
|
||||
): RoofGuideBounds {
|
||||
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
|
||||
return {
|
||||
centerX: center[0],
|
||||
centerZ: center[2],
|
||||
minX: center[0] - halfX,
|
||||
maxX: center[0] + halfX,
|
||||
minZ: center[2] - halfZ,
|
||||
maxZ: center[2] + halfZ,
|
||||
}
|
||||
}
|
||||
|
||||
export function roofSiblingSpacingGuides<T>(args: {
|
||||
segment: RoofSegmentNode
|
||||
movingId?: string
|
||||
movingBounds: RoofGuideBounds
|
||||
faceKey: string
|
||||
dimension: (id: string, from: [number, number], to: [number, number]) => T | null
|
||||
}): T[] {
|
||||
return roofSiblingSpacing(args).guides
|
||||
}
|
||||
|
||||
export function roofSiblingSpacing<T>(args: {
|
||||
segment: RoofSegmentNode
|
||||
movingId?: string
|
||||
movingBounds: RoofGuideBounds
|
||||
faceKey: string
|
||||
dimension: (id: string, from: [number, number], to: [number, number]) => T | null
|
||||
alignLine?: (id: string, from: [number, number], to: [number, number]) => T | null
|
||||
badge?: (id: string, at: [number, number], value: number) => T | null
|
||||
measure?: (from: [number, number], to: [number, number]) => number
|
||||
}): RoofSiblingSpacingResult<T> {
|
||||
const out: T[] = []
|
||||
const nodes = useScene.getState().nodes
|
||||
let left: { bounds: RoofGuideBounds; gap: number } | null = null
|
||||
let right: { bounds: RoofGuideBounds; gap: number } | null = null
|
||||
let bottom: { bounds: RoofGuideBounds; gap: number } | null = null
|
||||
let top: { bounds: RoofGuideBounds; gap: number } | null = null
|
||||
let xAlign: RoofAlignmentCandidate | null = null
|
||||
let zAlign: RoofAlignmentCandidate | null = null
|
||||
const xLane: RoofGuideBounds[] = []
|
||||
const zLane: RoofGuideBounds[] = []
|
||||
|
||||
for (const childId of args.segment.children ?? []) {
|
||||
if (childId === args.movingId) continue
|
||||
const sibling = nodes[childId as AnyNodeId]
|
||||
if (!isRoofGuideSibling(sibling)) continue
|
||||
const position = sibling.position
|
||||
if (!Array.isArray(position)) continue
|
||||
const siblingFace = getRoofSurfaceFaceBoundsAt(args.segment, position[0] ?? 0, position[2] ?? 0)
|
||||
if (roofFaceKey(siblingFace.polygon) !== args.faceKey) continue
|
||||
|
||||
const footprint = roofSurfaceFootprintFromNode(sibling, { segment: args.segment })
|
||||
const bounds = roofGuideBounds(position as [number, number, number], footprint)
|
||||
xAlign = nearerAlignment(xAlign, detectRoofAlignment(args.movingBounds, bounds, 'x'))
|
||||
zAlign = nearerAlignment(zAlign, detectRoofAlignment(args.movingBounds, bounds, 'z'))
|
||||
|
||||
if (sameGuideLane(args.movingBounds, bounds, 'x')) {
|
||||
xLane.push(bounds)
|
||||
const gapToLeft = args.movingBounds.minX - bounds.maxX
|
||||
if (gapToLeft > MIN_DIMENSION_M && (!left || gapToLeft < left.gap)) {
|
||||
left = { bounds, gap: gapToLeft }
|
||||
}
|
||||
const gapToRight = bounds.minX - args.movingBounds.maxX
|
||||
if (gapToRight > MIN_DIMENSION_M && (!right || gapToRight < right.gap)) {
|
||||
right = { bounds, gap: gapToRight }
|
||||
}
|
||||
}
|
||||
|
||||
if (sameGuideLane(args.movingBounds, bounds, 'z')) {
|
||||
zLane.push(bounds)
|
||||
const gapToBottom = args.movingBounds.minZ - bounds.maxZ
|
||||
if (gapToBottom > MIN_DIMENSION_M && (!bottom || gapToBottom < bottom.gap)) {
|
||||
bottom = { bounds, gap: gapToBottom }
|
||||
}
|
||||
const gapToTop = bounds.minZ - args.movingBounds.maxZ
|
||||
if (gapToTop > MIN_DIMENSION_M && (!top || gapToTop < top.gap)) {
|
||||
top = { bounds, gap: gapToTop }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (left) {
|
||||
const guide = args.dimension(
|
||||
'roof-sibling:left',
|
||||
[left.bounds.maxX, args.movingBounds.centerZ],
|
||||
[args.movingBounds.minX, args.movingBounds.centerZ],
|
||||
)
|
||||
if (guide) out.push(guide)
|
||||
}
|
||||
if (right) {
|
||||
const guide = args.dimension(
|
||||
'roof-sibling:right',
|
||||
[args.movingBounds.maxX, args.movingBounds.centerZ],
|
||||
[right.bounds.minX, args.movingBounds.centerZ],
|
||||
)
|
||||
if (guide) out.push(guide)
|
||||
}
|
||||
if (bottom) {
|
||||
const guide = args.dimension(
|
||||
'roof-sibling:bottom',
|
||||
[args.movingBounds.centerX, bottom.bounds.maxZ],
|
||||
[args.movingBounds.centerX, args.movingBounds.minZ],
|
||||
)
|
||||
if (guide) out.push(guide)
|
||||
}
|
||||
if (top) {
|
||||
const guide = args.dimension(
|
||||
'roof-sibling:top',
|
||||
[args.movingBounds.centerX, args.movingBounds.maxZ],
|
||||
[args.movingBounds.centerX, top.bounds.minZ],
|
||||
)
|
||||
if (guide) out.push(guide)
|
||||
}
|
||||
if (args.alignLine) {
|
||||
if (xAlign) {
|
||||
const guide = args.alignLine('roof-align:x', xAlign.from, xAlign.to)
|
||||
if (guide) out.push(guide)
|
||||
}
|
||||
if (zAlign) {
|
||||
const guide = args.alignLine('roof-align:z', zAlign.from, zAlign.to)
|
||||
if (guide) out.push(guide)
|
||||
}
|
||||
}
|
||||
if (args.badge) {
|
||||
pushRoofEqualSpacingBadges({
|
||||
axis: 'x',
|
||||
movingBounds: args.movingBounds,
|
||||
siblings: xLane,
|
||||
badge: args.badge,
|
||||
measure: args.measure,
|
||||
out,
|
||||
})
|
||||
pushRoofEqualSpacingBadges({
|
||||
axis: 'z',
|
||||
movingBounds: args.movingBounds,
|
||||
siblings: zLane,
|
||||
badge: args.badge,
|
||||
measure: args.measure,
|
||||
out,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
guides: out,
|
||||
blockedSides: {
|
||||
left: !!left,
|
||||
right: !!right,
|
||||
bottom: !!bottom,
|
||||
top: !!top,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function pushRoofEqualSpacingBadges<T>(args: {
|
||||
axis: 'x' | 'z'
|
||||
movingBounds: RoofGuideBounds
|
||||
siblings: RoofGuideBounds[]
|
||||
badge: (id: string, at: [number, number], value: number) => T | null
|
||||
measure?: (from: [number, number], to: [number, number]) => number
|
||||
out: T[]
|
||||
}): void {
|
||||
if (args.siblings.length < 2) return
|
||||
const items: RoofEqualSpacingItem[] = [
|
||||
{ bounds: args.movingBounds, moving: true },
|
||||
...args.siblings.map((bounds) => ({ bounds, moving: false })),
|
||||
].sort((a, b) =>
|
||||
args.axis === 'x' ? a.bounds.centerX - b.bounds.centerX : a.bounds.centerZ - b.bounds.centerZ,
|
||||
)
|
||||
const movingIndex = items.findIndex((item) => item.moving)
|
||||
if (movingIndex < 0) return
|
||||
|
||||
const gaps: RoofEqualSpacingGap[] = []
|
||||
for (let i = 0; i < items.length - 1; i++) {
|
||||
const a = items[i]
|
||||
const b = items[i + 1]
|
||||
if (!a || !b) continue
|
||||
const from: [number, number] =
|
||||
args.axis === 'x'
|
||||
? [a.bounds.maxX, args.movingBounds.centerZ]
|
||||
: [args.movingBounds.centerX, a.bounds.maxZ]
|
||||
const to: [number, number] =
|
||||
args.axis === 'x'
|
||||
? [b.bounds.minX, args.movingBounds.centerZ]
|
||||
: [args.movingBounds.centerX, b.bounds.minZ]
|
||||
const value = args.measure?.(from, to) ?? Math.hypot(to[0] - from[0], to[1] - from[1])
|
||||
gaps.push({ value, from, to })
|
||||
}
|
||||
|
||||
let best: { value: number; gaps: RoofEqualSpacingGap[] } | null = null
|
||||
for (let lo = 0; lo < gaps.length; lo++) {
|
||||
let min = Number.POSITIVE_INFINITY
|
||||
let max = Number.NEGATIVE_INFINITY
|
||||
for (let hi = lo; hi < gaps.length; hi++) {
|
||||
const gap = gaps[hi]
|
||||
if (!gap || gap.value < MIN_DIMENSION_M) break
|
||||
min = Math.min(min, gap.value)
|
||||
max = Math.max(max, gap.value)
|
||||
if (max - min > EQUAL_SPACING_THRESHOLD_M) break
|
||||
|
||||
const gapCount = hi - lo + 1
|
||||
if (gapCount < 2) continue
|
||||
const firstItem = lo
|
||||
const lastItem = hi + 1
|
||||
if (movingIndex < firstItem || movingIndex > lastItem) continue
|
||||
if (best !== null && gapCount <= best.gaps.length) continue
|
||||
|
||||
const run = gaps.slice(lo, hi + 1)
|
||||
best = {
|
||||
value: run.reduce((sum, g) => sum + g.value, 0) / run.length,
|
||||
gaps: run,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best?.gaps.forEach((gap, index) => {
|
||||
const guide = args.badge(
|
||||
`roof-spacing:${args.axis}:${index}`,
|
||||
mid2(gap.from, gap.to),
|
||||
best.value,
|
||||
)
|
||||
if (guide) args.out.push(guide)
|
||||
})
|
||||
}
|
||||
|
||||
function mid2(a: [number, number], b: [number, number]): [number, number] {
|
||||
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
||||
}
|
||||
|
||||
function sameGuideLane(a: RoofGuideBounds, b: RoofGuideBounds, axis: 'x' | 'z'): boolean {
|
||||
if (axis === 'x') {
|
||||
return valueWithinRange(a.centerZ, b.minZ, b.maxZ)
|
||||
}
|
||||
return valueWithinRange(a.centerX, b.minX, b.maxX)
|
||||
}
|
||||
|
||||
function valueWithinRange(value: number, min: number, max: number): boolean {
|
||||
return value >= min - ALIGNMENT_THRESHOLD_M && value <= max + ALIGNMENT_THRESHOLD_M
|
||||
}
|
||||
|
||||
function roofAlignmentSnap(args: {
|
||||
segment: RoofSegmentNode
|
||||
movingId?: string
|
||||
movingBounds: RoofGuideBounds
|
||||
faceKey: string
|
||||
}): { dx?: number; dz?: number } | null {
|
||||
const nodes = useScene.getState().nodes
|
||||
let bestX: { delta: number; gap: number } | null = null
|
||||
let bestZ: { delta: number; gap: number } | null = null
|
||||
|
||||
for (const childId of args.segment.children ?? []) {
|
||||
if (childId === args.movingId) continue
|
||||
const sibling = nodes[childId as AnyNodeId]
|
||||
if (!isRoofGuideSibling(sibling)) continue
|
||||
const position = sibling.position
|
||||
if (!Array.isArray(position)) continue
|
||||
const siblingFace = getRoofSurfaceFaceBoundsAt(args.segment, position[0] ?? 0, position[2] ?? 0)
|
||||
if (roofFaceKey(siblingFace.polygon) !== args.faceKey) continue
|
||||
|
||||
const footprint = roofSurfaceFootprintFromNode(sibling, { segment: args.segment })
|
||||
const siblingBounds = roofGuideBounds(position as [number, number, number], footprint)
|
||||
bestX = nearerSnap(bestX, detectRoofAlignmentSnap(args.movingBounds, siblingBounds, 'x'))
|
||||
bestZ = nearerSnap(bestZ, detectRoofAlignmentSnap(args.movingBounds, siblingBounds, 'z'))
|
||||
}
|
||||
|
||||
if (!bestX && !bestZ) return null
|
||||
return {
|
||||
dx: bestX?.delta,
|
||||
dz: bestZ?.delta,
|
||||
}
|
||||
}
|
||||
|
||||
function detectRoofAlignmentSnap(
|
||||
moving: RoofGuideBounds,
|
||||
sibling: RoofGuideBounds,
|
||||
axis: 'x' | 'z',
|
||||
): { delta: number; gap: number } | null {
|
||||
let best: { delta: number; gap: number } | null = null
|
||||
for (const movingFeature of ROOF_ALIGNMENT_FEATURES) {
|
||||
const movingCoord = roofFeatureCoord(moving, axis, movingFeature)
|
||||
for (const siblingFeature of ROOF_ALIGNMENT_FEATURES) {
|
||||
const siblingCoord = roofFeatureCoord(sibling, axis, siblingFeature)
|
||||
const delta = siblingCoord - movingCoord
|
||||
const gap = Math.abs(delta)
|
||||
if (gap <= ALIGNMENT_THRESHOLD_M && (!best || gap < best.gap)) {
|
||||
best = { delta, gap }
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function nearerSnap(
|
||||
current: { delta: number; gap: number } | null,
|
||||
candidate: { delta: number; gap: number } | null,
|
||||
): { delta: number; gap: number } | null {
|
||||
if (!candidate) return current
|
||||
if (!current || candidate.gap < current.gap) return candidate
|
||||
return current
|
||||
}
|
||||
|
||||
function detectRoofAlignment(
|
||||
moving: RoofGuideBounds,
|
||||
sibling: RoofGuideBounds,
|
||||
axis: 'x' | 'z',
|
||||
): RoofAlignmentCandidate | null {
|
||||
let best: RoofAlignmentCandidate | null = null
|
||||
for (const movingFeature of ROOF_ALIGNMENT_FEATURES) {
|
||||
const movingCoord = roofFeatureCoord(moving, axis, movingFeature)
|
||||
for (const siblingFeature of ROOF_ALIGNMENT_FEATURES) {
|
||||
const siblingCoord = roofFeatureCoord(sibling, axis, siblingFeature)
|
||||
const gap = Math.abs(siblingCoord - movingCoord)
|
||||
if (gap > ALIGNMENT_THRESHOLD_M || (best && gap >= best.gap)) continue
|
||||
const coord = siblingCoord
|
||||
if (axis === 'x') {
|
||||
best = {
|
||||
axis,
|
||||
coord,
|
||||
gap,
|
||||
from: [coord, Math.min(moving.minZ, sibling.minZ)],
|
||||
to: [coord, Math.max(moving.maxZ, sibling.maxZ)],
|
||||
}
|
||||
} else {
|
||||
best = {
|
||||
axis,
|
||||
coord,
|
||||
gap,
|
||||
from: [Math.min(moving.minX, sibling.minX), coord],
|
||||
to: [Math.max(moving.maxX, sibling.maxX), coord],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
const ROOF_ALIGNMENT_FEATURES: RoofAlignmentFeature[] = ['center', 'min', 'max']
|
||||
|
||||
function roofFeatureCoord(
|
||||
bounds: RoofGuideBounds,
|
||||
axis: 'x' | 'z',
|
||||
feature: RoofAlignmentFeature,
|
||||
): number {
|
||||
if (axis === 'x') {
|
||||
if (feature === 'min') return bounds.minX
|
||||
if (feature === 'max') return bounds.maxX
|
||||
return bounds.centerX
|
||||
}
|
||||
if (feature === 'min') return bounds.minZ
|
||||
if (feature === 'max') return bounds.maxZ
|
||||
return bounds.centerZ
|
||||
}
|
||||
|
||||
function nearerAlignment(
|
||||
current: RoofAlignmentCandidate | null,
|
||||
candidate: RoofAlignmentCandidate | null,
|
||||
): RoofAlignmentCandidate | null {
|
||||
if (!candidate) return current
|
||||
if (!current || candidate.gap < current.gap) return candidate
|
||||
return current
|
||||
}
|
||||
|
||||
function isRoofGuideSibling(node: AnyNode | undefined): node is AnyNode & {
|
||||
position: readonly [number, number, number]
|
||||
} {
|
||||
if (!node || !Array.isArray((node as { position?: unknown }).position)) return false
|
||||
switch (node.type) {
|
||||
case 'box-vent':
|
||||
case 'turbine-vent':
|
||||
case 'eyebrow-vent':
|
||||
case 'solar-panel':
|
||||
case 'skylight':
|
||||
case 'cupola':
|
||||
case 'chimney':
|
||||
case 'ridge-vent':
|
||||
case 'gutter':
|
||||
case 'dormer':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function roofFaceKey(polygon: readonly (readonly [number, number])[]): string {
|
||||
return polygon.map(([x, z]) => `${roundKey(x)}:${roundKey(z)}`).join('|')
|
||||
}
|
||||
|
||||
function roundKey(value: number): string {
|
||||
return value.toFixed(4)
|
||||
}
|
||||
|
||||
function numberField(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
@@ -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>): 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AnyNode> }[] {
|
||||
const preview = { ...(this.node as Record<string, unknown>), ...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<string, unknown>] 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<AnyNode> }[] {
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
type PortConnection,
|
||||
} from '@pascal-app/core'
|
||||
import { getDuctFittingPorts } from '../duct-fitting/ports'
|
||||
import { type DuctProfile, planElbowAtPort, profileDiameterIn } from './auto-fitting'
|
||||
import type { ScenePort } from './ports'
|
||||
import { planRunTranslationOffsets } from './run-translation-offset'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
const RECT_PROFILE: DuctProfile = { shape: 'rect', diameter: 6, width: 14, height: 8 }
|
||||
|
||||
function rectRun(path: Point[]): DuctSegmentNode {
|
||||
return DuctSegmentNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Trunk',
|
||||
path,
|
||||
shape: 'rect',
|
||||
diameter: 6,
|
||||
width: 14,
|
||||
height: 8,
|
||||
roll: 0,
|
||||
ductMaterial: 'sheet-metal',
|
||||
insulationR: 0,
|
||||
system: 'supply',
|
||||
})
|
||||
}
|
||||
|
||||
function runConnection(run: DuctSegmentNode): PortConnection {
|
||||
return {
|
||||
kind: 'run',
|
||||
nodeId: run.id,
|
||||
startPath: run.path,
|
||||
}
|
||||
}
|
||||
|
||||
function fittingConnection(fitting: DuctFittingNode): PortConnection {
|
||||
return {
|
||||
kind: 'rigid-node',
|
||||
nodeId: fitting.id,
|
||||
startPosition: fitting.position,
|
||||
}
|
||||
}
|
||||
|
||||
function runPort(run: DuctSegmentNode, point: Point, direction: Point): ScenePort {
|
||||
return {
|
||||
id: 'end',
|
||||
nodeId: run.id,
|
||||
position: point,
|
||||
direction,
|
||||
diameter: 12,
|
||||
system: 'supply',
|
||||
}
|
||||
}
|
||||
|
||||
function portLike(position: Point, direction: Point): ScenePort {
|
||||
return {
|
||||
id: 'x',
|
||||
nodeId: 'x' as AnyNode['id'],
|
||||
position,
|
||||
direction,
|
||||
diameter: 12,
|
||||
system: 'supply',
|
||||
}
|
||||
}
|
||||
|
||||
function distSq(a: readonly number[], b: 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
|
||||
}
|
||||
|
||||
describe('planRunTranslationOffsets', () => {
|
||||
test('slides a connected run sideways by adding elbows and a connector', () => {
|
||||
const moved = rectRun([
|
||||
[0, 0, 0],
|
||||
[4, 0, 0],
|
||||
])
|
||||
const partner = rectRun([
|
||||
[-4, 0, 0],
|
||||
[0, 0, 0],
|
||||
])
|
||||
const translatedPath = moved.path.map((p) => [p[0], p[1], p[2] - 1.2] as Point)
|
||||
|
||||
const result = planRunTranslationOffsets({
|
||||
duct: moved,
|
||||
translatedPath,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [runConnection(partner)],
|
||||
scenePorts: [runPort(partner, [0, 0, 0], [1, 0, 0])],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[partner.id]: partner as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
if (!result) return
|
||||
expect(result.fittings).toHaveLength(2)
|
||||
expect(result.connectors).toHaveLength(1)
|
||||
expect(result.updates.some((u) => u.id === partner.id)).toBe(true)
|
||||
expect(result.ductPath[0]![2]).toBeLessThan(0)
|
||||
expect(result.connectors[0]!.path[0]![2]).toBeLessThan(0)
|
||||
expect(result.connectors[0]!.path[1]![2]).toBeGreaterThan(-1.2)
|
||||
expect(result.connectors[0]!.path[0]![2]).toBeGreaterThan(result.connectors[0]!.path[1]![2])
|
||||
})
|
||||
|
||||
test('re-aims an existing elbow and inserts the missing connector', () => {
|
||||
const elbowPlan = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 0, -1], RECT_PROFILE)
|
||||
expect(elbowPlan).toBeTruthy()
|
||||
if (!elbowPlan) return
|
||||
const elbow = DuctFittingNode.parse({
|
||||
...elbowPlan.fitting,
|
||||
diameter: profileDiameterIn(RECT_PROFILE),
|
||||
diameter2: profileDiameterIn(RECT_PROFILE),
|
||||
})
|
||||
const branchPort = getDuctFittingPorts(elbow).find(
|
||||
(p) => distSq(p.position, elbowPlan.collarPoint) < 1e-9,
|
||||
)!
|
||||
const moved = rectRun([
|
||||
[...branchPort.position],
|
||||
[branchPort.position[0] + 4, branchPort.position[1], branchPort.position[2]],
|
||||
])
|
||||
const translatedPath = moved.path.map((p) => [p[0], p[1], p[2] - 1.2] as Point)
|
||||
|
||||
const result = planRunTranslationOffsets({
|
||||
duct: moved,
|
||||
translatedPath,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [fittingConnection(elbow)],
|
||||
scenePorts: [{ ...branchPort, nodeId: elbow.id }],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[elbow.id]: elbow as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
if (!result) return
|
||||
expect(result.fittings).toHaveLength(1)
|
||||
expect(result.connectors).toHaveLength(1)
|
||||
expect(result.updates.some((u) => u.id === elbow.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
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'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
const COINCIDENT_EPS_M = 0.05
|
||||
const MIN_CONNECTOR_M = 0.05
|
||||
|
||||
export type RunTranslationOffsetPlan = {
|
||||
ductPath: Point[]
|
||||
fittings: DuctFittingNode[]
|
||||
connectors: DuctSegmentNode[]
|
||||
updates: { id: AnyNodeId; data: Partial<AnyNode> }[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function sub(a: Point, b: Point): Point {
|
||||
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
function neg(v: Point): Point {
|
||||
return [-v[0], -v[1], -v[2]]
|
||||
}
|
||||
|
||||
function unit(v: Point): Point | null {
|
||||
const len = Math.hypot(v[0], v[1], v[2])
|
||||
if (len < 1e-9) return null
|
||||
return [v[0] / len, v[1] / len, v[2] / len]
|
||||
}
|
||||
|
||||
function endpointOutwardDir(path: ReadonlyArray<readonly number[]>, idx: number): Point {
|
||||
const last = path.length - 1
|
||||
const [a, b] = idx === 0 ? [path[0]!, path[1]!] : [path[last]!, path[last - 1]!]
|
||||
return unit([a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!]) ?? [1, 0, 0]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function connectorRun(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,
|
||||
})
|
||||
}
|
||||
|
||||
function elbowProfilePatch(profile: DuctProfile): Partial<DuctFittingNode> {
|
||||
const diameter = profileDiameterIn(profile)
|
||||
return {
|
||||
shape: profile.shape,
|
||||
width: profile.width,
|
||||
height: profile.height,
|
||||
diameter,
|
||||
diameter2: diameter,
|
||||
}
|
||||
}
|
||||
|
||||
export function planRunTranslationOffsets(args: {
|
||||
duct: DuctSegmentNode
|
||||
translatedPath: Point[]
|
||||
profile: DuctProfile
|
||||
connections: PortConnection[]
|
||||
scenePorts: ScenePort[]
|
||||
nodesById: Record<string, AnyNode>
|
||||
}): RunTranslationOffsetPlan | null {
|
||||
const { duct, translatedPath, profile, connections, scenePorts, nodesById } = args
|
||||
if (duct.path.length < 2 || translatedPath.length !== duct.path.length) return null
|
||||
if (connections.length === 0) return null
|
||||
|
||||
const leg = fittingLegLength(profileDiameterIn(profile))
|
||||
const minOffset = 2 * leg + MIN_CONNECTOR_M
|
||||
const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M
|
||||
const ductPath = translatedPath.map((p) => [...p] as Point)
|
||||
const fittings: DuctFittingNode[] = []
|
||||
const connectors: DuctSegmentNode[] = []
|
||||
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
||||
let routedAny = false
|
||||
|
||||
for (const endIdx of duct.path.length > 1 ? [0, duct.path.length - 1] : [0]) {
|
||||
const startEnd = duct.path[endIdx]!
|
||||
const movedEnd = translatedPath[endIdx]!
|
||||
const delta = sub(movedEnd, startEnd)
|
||||
const offsetDir = unit(delta)
|
||||
if (!offsetDir || Math.hypot(delta[0], delta[1], delta[2]) < minOffset) continue
|
||||
|
||||
const partnerPort = scenePorts.find(
|
||||
(sp) =>
|
||||
distSq(sp.position, startEnd) <= eps2 &&
|
||||
connections.some((conn) => conn.nodeId === sp.nodeId),
|
||||
)
|
||||
if (!partnerPort) continue
|
||||
const conn = connections.find((c) => c.nodeId === partnerPort.nodeId)
|
||||
if (!conn) continue
|
||||
|
||||
const ductPortDir = endpointOutwardDir(translatedPath, endIdx)
|
||||
const top = planElbowAtPort(
|
||||
portLike(movedEnd, ductPortDir, duct.system),
|
||||
neg(offsetDir),
|
||||
profile,
|
||||
)
|
||||
if (!top) return null
|
||||
|
||||
if (conn.kind === 'run') {
|
||||
const bottom = planElbowAtPort(
|
||||
portLike(
|
||||
[startEnd[0], startEnd[1], startEnd[2]],
|
||||
[partnerPort.direction[0], partnerPort.direction[1], partnerPort.direction[2]],
|
||||
duct.system,
|
||||
),
|
||||
offsetDir,
|
||||
profile,
|
||||
)
|
||||
if (!bottom) return null
|
||||
fittings.push(bottom.fitting, top.fitting)
|
||||
connectors.push(connectorRun(bottom.collarPoint, top.collarPoint, duct))
|
||||
ductPath[endIdx] = top.trimmedPortPoint
|
||||
|
||||
const path = conn.startPath.map((p) => [...p] as Point)
|
||||
const tip = path.findIndex((p) => distSq(p, startEnd) <= eps2)
|
||||
if (tip !== -1) {
|
||||
path[tip] = bottom.trimmedPortPoint
|
||||
updates.push({ id: conn.nodeId, data: { path } as Partial<AnyNode> })
|
||||
}
|
||||
routedAny = true
|
||||
continue
|
||||
}
|
||||
|
||||
const partner = nodesById[conn.nodeId]
|
||||
if (!partner || partner.type !== 'duct-fitting') return null
|
||||
const elbow = {
|
||||
...(partner as DuctFittingNode),
|
||||
...elbowProfilePatch(profile),
|
||||
} as DuctFittingNode
|
||||
if (elbow.fittingType !== 'elbow') return null
|
||||
const realign = planElbowRealign(elbow, partnerPort.id, offsetDir)
|
||||
if (!realign) return null
|
||||
|
||||
fittings.push(top.fitting)
|
||||
connectors.push(connectorRun(realign.collarPoint, top.collarPoint, duct))
|
||||
ductPath[endIdx] = top.trimmedPortPoint
|
||||
updates.push({
|
||||
id: elbow.id,
|
||||
data: { ...elbowProfilePatch(profile), ...realign.update.data } as Partial<AnyNode>,
|
||||
})
|
||||
routedAny = true
|
||||
}
|
||||
|
||||
if (!routedAny) return null
|
||||
return { ductPath, fittings, connectors, updates }
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import type { Cursor } from '@pascal-app/core'
|
||||
import { ARROW_SCALE, HandleArrow, swallowNextClick } from '@pascal-app/editor'
|
||||
import type { ThreeEvent } from '@react-three/fiber'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useState } from 'react'
|
||||
import { OrthographicCamera } from 'three'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
function consumeHandlePress(event: ThreeEvent<PointerEvent>) {
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.stopPropagation()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
swallowNextClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Small persistent cube the user CLICKS to latch a directional handle cluster
|
||||
* open (click again to close). A `tracker` HandleArrow (a tiny cube) reused so
|
||||
* it shares the rig's hit-area / depth / outline treatment, sized to match the
|
||||
* roof-segment pitch cube (`baseScale = zoom`, full `TRACKER_CUBE_SIZE`).
|
||||
* `hoverScale = 1.15` grows it 15% on hover / while its cluster is open so it
|
||||
* reads as clickable. Shared by the duct-segment and duct-fitting selection
|
||||
* rigs so every editing cube is the same size.
|
||||
*/
|
||||
export function HandleCube({
|
||||
position,
|
||||
active,
|
||||
onClick,
|
||||
onPointerDown,
|
||||
rotationY = 0,
|
||||
cursor = 'grab',
|
||||
}: {
|
||||
position: Point
|
||||
active: boolean
|
||||
onClick?: () => void
|
||||
onPointerDown?: (e: ThreeEvent<PointerEvent>) => void
|
||||
/** Yaw (radians) so the cube can align with the run it sits on. */
|
||||
rotationY?: number
|
||||
cursor?: Cursor
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const { camera } = useThree()
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const baseScale = zoom
|
||||
return (
|
||||
<HandleArrow
|
||||
cursor={cursor}
|
||||
hover={hovered || active}
|
||||
hoverScale={1.15}
|
||||
onHoverChange={setHovered}
|
||||
onPointerDown={(e) => {
|
||||
consumeHandlePress(e)
|
||||
if (onPointerDown) onPointerDown(e)
|
||||
else onClick?.()
|
||||
}}
|
||||
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
||||
shape="tracker"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* In-world chevron arrow handle — a thin wrapper over the editor's shared
|
||||
* `HandleArrow` so directional move arrows render as the same solid violet
|
||||
* plate (depth-written, ink-edge outlined) the wall arrows use. Lays flat in
|
||||
* the XZ plane pointing along +X (yawed by `rotationY`); `vertical` tips the
|
||||
* chevron up / down for the riser pair. Scales with ortho zoom for a constant
|
||||
* on-screen size.
|
||||
*/
|
||||
export function MoveChevron({
|
||||
position,
|
||||
rotationY = 0,
|
||||
vertical,
|
||||
cursor = 'grab',
|
||||
onPointerDown,
|
||||
}: {
|
||||
position: Point
|
||||
rotationY?: number
|
||||
vertical?: 'up' | 'down'
|
||||
cursor?: Cursor
|
||||
onPointerDown: (e: ThreeEvent<PointerEvent>) => void
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const { camera } = useThree()
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const baseScale = zoom * ARROW_SCALE
|
||||
// Tip the flat chevron up / down to point along ±Y — the same inner-rotation
|
||||
// chain the wall height arrow uses.
|
||||
const indicatorRotation: [number, number, number] | undefined = vertical
|
||||
? [0, Math.PI / 2, vertical === 'up' ? Math.PI / 2 : -Math.PI / 2]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<HandleArrow
|
||||
cursor={cursor}
|
||||
hover={hovered}
|
||||
indicatorRotation={indicatorRotation}
|
||||
onHoverChange={setHovered}
|
||||
onPointerDown={(event) => {
|
||||
consumeHandlePress(event)
|
||||
onPointerDown(event)
|
||||
}}
|
||||
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
||||
shape="chevron"
|
||||
thin
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotation arc handle — the editor's `curved-arrow` (which wraps world +Y by
|
||||
* default) re-oriented by an arbitrary `rotation` euler. Scales with ortho zoom
|
||||
* for a constant on-screen size. The caller supplies the position + orientation
|
||||
* so the same component serves a duct's single roll arc and a fitting's three
|
||||
* per-axis arcs.
|
||||
*/
|
||||
export function RotateArc({
|
||||
position,
|
||||
rotation,
|
||||
cursor = 'grab',
|
||||
onPointerDown,
|
||||
}: {
|
||||
position: Point
|
||||
rotation: [number, number, number]
|
||||
cursor?: Cursor
|
||||
onPointerDown: (e: ThreeEvent<PointerEvent>) => void
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const { camera } = useThree()
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const baseScale = zoom * ARROW_SCALE
|
||||
return (
|
||||
<HandleArrow
|
||||
cursor={cursor}
|
||||
hover={hovered}
|
||||
onHoverChange={setHovered}
|
||||
onPointerDown={(event) => {
|
||||
consumeHandlePress(event)
|
||||
onPointerDown(event)
|
||||
}}
|
||||
placement={{ position, rotation, baseScale }}
|
||||
shape="curved-arrow"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
type PortConnection,
|
||||
} from '@pascal-app/core'
|
||||
import { getDuctFittingPorts } from '../duct-fitting/ports'
|
||||
import { type DuctProfile, planElbowAtPort, profileDiameterIn } from './auto-fitting'
|
||||
import type { ScenePort } from './ports'
|
||||
import { planVerticalOffsets } from './vertical-offset'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
const RECT_PROFILE: DuctProfile = { shape: 'rect', diameter: 6, width: 14, height: 8 }
|
||||
|
||||
function distSq(a: readonly number[], b: 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
|
||||
}
|
||||
|
||||
function rectRun(path: Point[]): DuctSegmentNode {
|
||||
return DuctSegmentNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Trunk',
|
||||
path,
|
||||
shape: 'rect',
|
||||
diameter: 6,
|
||||
width: 14,
|
||||
height: 8,
|
||||
roll: 0,
|
||||
ductMaterial: 'sheet-metal',
|
||||
insulationR: 0,
|
||||
system: 'supply',
|
||||
})
|
||||
}
|
||||
|
||||
function runConnection(run: DuctSegmentNode): PortConnection {
|
||||
return {
|
||||
kind: 'run',
|
||||
nodeId: run.id,
|
||||
startPath: run.path,
|
||||
}
|
||||
}
|
||||
|
||||
function runPort(run: DuctSegmentNode, point: Point, direction: Point): ScenePort {
|
||||
return {
|
||||
id: 'end',
|
||||
nodeId: run.id,
|
||||
position: point,
|
||||
direction,
|
||||
diameter: 12,
|
||||
system: 'supply',
|
||||
}
|
||||
}
|
||||
|
||||
function portLike(position: Point, direction: Point): ScenePort {
|
||||
return {
|
||||
id: 'x',
|
||||
nodeId: 'x' as AnyNode['id'],
|
||||
position,
|
||||
direction,
|
||||
diameter: 12,
|
||||
system: 'supply',
|
||||
}
|
||||
}
|
||||
|
||||
function fittingConnection(fitting: DuctFittingNode): PortConnection {
|
||||
return {
|
||||
kind: 'rigid-node',
|
||||
nodeId: fitting.id,
|
||||
startPosition: fitting.position,
|
||||
}
|
||||
}
|
||||
|
||||
describe('planVerticalOffsets', () => {
|
||||
test.each([
|
||||
{ label: 'upward', y: 0, dy: 1.2 },
|
||||
{ label: 'downward', y: 2, dy: -1.2 },
|
||||
])('rolls the minted plumb riser through a rectangular $label offset', ({ y, dy }) => {
|
||||
const moved = rectRun([
|
||||
[0, y, 0],
|
||||
[4, y, 0],
|
||||
])
|
||||
const partner = rectRun([
|
||||
[-4, y, 0],
|
||||
[0, y, 0],
|
||||
])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
duct: moved,
|
||||
dy,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [runConnection(partner)],
|
||||
scenePorts: [runPort(partner, [0, y, 0], [1, 0, 0])],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[partner.id]: partner as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.risers).toHaveLength(1)
|
||||
expect(result.plan.risers[0]!.roll).toBeCloseTo(Math.PI / 2, 6)
|
||||
})
|
||||
|
||||
test('re-aims and resizes an existing flat elbow before routing the vertical L', () => {
|
||||
const elbow = DuctFittingNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Old elbow',
|
||||
fittingType: 'elbow',
|
||||
shape: 'rect',
|
||||
width: 8,
|
||||
height: 4,
|
||||
diameter: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
|
||||
diameter2: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
|
||||
ductMaterial: 'sheet-metal',
|
||||
system: 'supply',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
angle: 90,
|
||||
})
|
||||
const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')!
|
||||
const moved = rectRun([
|
||||
[...inlet.position],
|
||||
[inlet.position[0] - 4, inlet.position[1], inlet.position[2]],
|
||||
])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
duct: moved,
|
||||
dy: 1.2,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [fittingConnection(elbow)],
|
||||
scenePorts: [{ ...inlet, nodeId: elbow.id }],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[elbow.id]: elbow as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.risers).toHaveLength(1)
|
||||
expect(result.plan.risers[0]!.roll).toBeCloseTo(Math.PI / 2, 6)
|
||||
|
||||
const elbowUpdate = result.plan.updates.find((u) => u.id === elbow.id)
|
||||
expect(elbowUpdate?.data).toMatchObject({
|
||||
shape: 'rect',
|
||||
width: RECT_PROFILE.width,
|
||||
height: RECT_PROFILE.height,
|
||||
diameter: profileDiameterIn(RECT_PROFILE),
|
||||
})
|
||||
expect(elbowUpdate?.data.rotation).toBeDefined()
|
||||
expect(elbowUpdate?.data.angle).toBeDefined()
|
||||
})
|
||||
|
||||
test('fitting-connected offsets keep every minted collar touching the lifted run', () => {
|
||||
const elbow = DuctFittingNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Angled elbow',
|
||||
fittingType: 'elbow',
|
||||
shape: 'rect',
|
||||
width: 8,
|
||||
height: 4,
|
||||
diameter: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
|
||||
diameter2: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
|
||||
ductMaterial: 'sheet-metal',
|
||||
system: 'supply',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
angle: 45,
|
||||
})
|
||||
const outlet = getDuctFittingPorts(elbow).find((p) => p.id === 'outlet')!
|
||||
const angle = Math.PI / 4
|
||||
const moved = rectRun([
|
||||
[...outlet.position],
|
||||
[
|
||||
outlet.position[0] + Math.cos(angle) * 4,
|
||||
outlet.position[1],
|
||||
outlet.position[2] + Math.sin(angle) * 4,
|
||||
],
|
||||
])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
duct: moved,
|
||||
dy: 1.2,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [fittingConnection(elbow)],
|
||||
scenePorts: [{ ...outlet, nodeId: elbow.id }],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[elbow.id]: elbow as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.fittings).toHaveLength(1)
|
||||
expect(result.plan.risers).toHaveLength(1)
|
||||
|
||||
const topPorts = getDuctFittingPorts(result.plan.fittings[0]!)
|
||||
const riser = result.plan.risers[0]!
|
||||
expect(topPorts.some((p) => distSq(p.position, result.plan.ductPath[0]!) < 1e-9)).toBe(true)
|
||||
expect(topPorts.some((p) => distSq(p.position, riser.path[1]!) < 1e-9)).toBe(true)
|
||||
|
||||
const elbowUpdate = result.plan.updates.find((u) => u.id === elbow.id)
|
||||
const reaimedElbow = DuctFittingNode.parse({ ...elbow, ...elbowUpdate?.data })
|
||||
const reaimedPorts = getDuctFittingPorts(reaimedElbow)
|
||||
expect(reaimedPorts.some((p) => distSq(p.position, riser.path[0]!) < 1e-9)).toBe(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ label: 'tee branch', fittingType: 'tee' as const, portId: 'branch' },
|
||||
{ label: 'cross branch', fittingType: 'cross' as const, portId: 'branch' },
|
||||
])('routes a vertical offset from a stationary $label fitting', ({ fittingType, portId }) => {
|
||||
const fitting = DuctFittingNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: fittingType,
|
||||
fittingType,
|
||||
shape: 'rect',
|
||||
width: RECT_PROFILE.width,
|
||||
height: RECT_PROFILE.height,
|
||||
diameter: profileDiameterIn(RECT_PROFILE),
|
||||
shape2: 'rect',
|
||||
width2: RECT_PROFILE.width,
|
||||
height2: RECT_PROFILE.height,
|
||||
diameter2: profileDiameterIn(RECT_PROFILE),
|
||||
ductMaterial: 'sheet-metal',
|
||||
system: 'supply',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
angle: 90,
|
||||
branchAngle: 90,
|
||||
})
|
||||
const fittingPorts = getDuctFittingPorts(fitting)
|
||||
const branch = fittingPorts.find((p) => p.id === portId)!
|
||||
const moved = rectRun([
|
||||
[...branch.position],
|
||||
[
|
||||
branch.position[0] + branch.direction[0] * 4,
|
||||
branch.position[1] + branch.direction[1] * 4,
|
||||
branch.position[2] + branch.direction[2] * 4,
|
||||
],
|
||||
])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
duct: moved,
|
||||
dy: 1.2,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [fittingConnection(fitting)],
|
||||
scenePorts: fittingPorts.map((p) => ({ ...p, nodeId: fitting.id })),
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.fittings).toHaveLength(2)
|
||||
expect(result.plan.risers).toHaveLength(1)
|
||||
expect(result.plan.updates.some((u) => u.id === fitting.id)).toBe(false)
|
||||
expect(result.plan.followPath[0]).toEqual(moved.path[0])
|
||||
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(branch.position[1] + 1.2, 6)
|
||||
const bottomPorts = getDuctFittingPorts(result.plan.fittings[0]!)
|
||||
const topPorts = getDuctFittingPorts(result.plan.fittings[1]!)
|
||||
const riser = result.plan.risers[0]!
|
||||
expect(bottomPorts.some((p) => distSq(p.position, branch.position) < 1e-9)).toBe(true)
|
||||
expect(bottomPorts.some((p) => distSq(p.position, riser.path[0]!) < 1e-9)).toBe(true)
|
||||
expect(topPorts.some((p) => distSq(p.position, riser.path[1]!) < 1e-9)).toBe(true)
|
||||
expect(topPorts.some((p) => distSq(p.position, result.plan.ductPath[0]!) < 1e-9)).toBe(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ label: 'up', dy: 1 },
|
||||
{ label: 'down', dy: -0.5 },
|
||||
])('$label moves an elbow-connected top run by stretching the existing vertical riser', ({
|
||||
dy,
|
||||
}) => {
|
||||
const elbow = DuctFittingNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Top corner elbow',
|
||||
fittingType: 'elbow',
|
||||
shape: 'rect',
|
||||
width: RECT_PROFILE.width,
|
||||
height: RECT_PROFILE.height,
|
||||
diameter: profileDiameterIn(RECT_PROFILE),
|
||||
diameter2: profileDiameterIn(RECT_PROFILE),
|
||||
ductMaterial: 'sheet-metal',
|
||||
system: 'supply',
|
||||
position: [0, 2, 0],
|
||||
rotation: [0, 0, 0],
|
||||
angle: 90,
|
||||
})
|
||||
const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')!
|
||||
const outlet = getDuctFittingPorts(elbow).find((p) => p.id === 'outlet')!
|
||||
const moved = rectRun([
|
||||
[...inlet.position],
|
||||
[inlet.position[0] - 4, inlet.position[1], inlet.position[2]],
|
||||
])
|
||||
const riser = rectRun([[outlet.position[0], 0, outlet.position[2]], [...outlet.position]])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
duct: moved,
|
||||
dy,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [fittingConnection(elbow), runConnection(riser)],
|
||||
scenePorts: [
|
||||
{ ...inlet, nodeId: elbow.id },
|
||||
{ ...outlet, nodeId: elbow.id },
|
||||
runPort(riser, [...outlet.position], [0, 1, 0]),
|
||||
],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[elbow.id]: elbow as AnyNode,
|
||||
[riser.id]: riser as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.fittings).toHaveLength(0)
|
||||
expect(result.plan.risers).toHaveLength(0)
|
||||
expect(result.plan.followPath[0]?.[1]).toBeCloseTo(inlet.position[1] + dy, 6)
|
||||
})
|
||||
|
||||
test('collapses an elbow-riser-elbow side into one elbow when the top run aligns downward', () => {
|
||||
const moved = rectRun([
|
||||
[0, 0, 0],
|
||||
[4, 0, 0],
|
||||
])
|
||||
const partner = rectRun([
|
||||
[-4, 0, 0],
|
||||
[0, 0, 0],
|
||||
])
|
||||
const upward = planVerticalOffsets({
|
||||
duct: moved,
|
||||
dy: 1.2,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [runConnection(partner)],
|
||||
scenePorts: [runPort(partner, [0, 0, 0], [1, 0, 0])],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[partner.id]: partner as AnyNode,
|
||||
},
|
||||
})
|
||||
expect(upward?.status).toBe('valid')
|
||||
if (upward?.status !== 'valid') return
|
||||
const [bottom, top] = upward.plan.fittings
|
||||
const [riser] = upward.plan.risers
|
||||
expect(bottom).toBeDefined()
|
||||
expect(top).toBeDefined()
|
||||
expect(riser).toBeDefined()
|
||||
const topRun = DuctSegmentNode.parse({ ...moved, path: upward.plan.ductPath })
|
||||
const topPorts = getDuctFittingPorts(top!)
|
||||
const bottomPorts = getDuctFittingPorts(bottom!)
|
||||
|
||||
const collapseDy = -topRun.path[0]![1]
|
||||
const result = planVerticalOffsets({
|
||||
duct: topRun,
|
||||
dy: collapseDy,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [fittingConnection(top!), runConnection(riser!), fittingConnection(bottom!)],
|
||||
scenePorts: [
|
||||
...topPorts.map((p) => ({ ...p, nodeId: top!.id })),
|
||||
...bottomPorts.map((p) => ({ ...p, nodeId: bottom!.id })),
|
||||
runPort(riser!, riser!.path[0]!, [0, -1, 0]),
|
||||
runPort(riser!, riser!.path[1]!, [0, 1, 0]),
|
||||
],
|
||||
nodesById: {
|
||||
[topRun.id]: topRun as AnyNode,
|
||||
[top!.id]: top! as AnyNode,
|
||||
[bottom!.id]: bottom! as AnyNode,
|
||||
[riser!.id]: riser! as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.fittings).toHaveLength(0)
|
||||
expect(result.plan.risers).toHaveLength(0)
|
||||
expect(result.plan.delete).toEqual(expect.arrayContaining([top!.id, riser!.id]))
|
||||
expect(result.plan.updates.some((u) => u.id === bottom!.id)).toBe(true)
|
||||
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(result.plan.ductPath[1]?.[1] ?? 999, 6)
|
||||
})
|
||||
|
||||
test('collapses only the aligned side while shortening the still-offset side', () => {
|
||||
const leftBottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
||||
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
||||
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
|
||||
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
|
||||
|
||||
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
|
||||
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
|
||||
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
|
||||
const leftBottomPorts = getDuctFittingPorts(leftBottom.fitting)
|
||||
const leftTopPorts = getDuctFittingPorts(leftTop.fitting)
|
||||
const rightBottomPorts = getDuctFittingPorts(rightBottom.fitting)
|
||||
const rightTopPorts = getDuctFittingPorts(rightTop.fitting)
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
duct: topRun,
|
||||
dy: -1.2,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [
|
||||
fittingConnection(leftTop.fitting),
|
||||
fittingConnection(rightTop.fitting),
|
||||
runConnection(leftRiser),
|
||||
runConnection(rightRiser),
|
||||
fittingConnection(leftBottom.fitting),
|
||||
fittingConnection(rightBottom.fitting),
|
||||
],
|
||||
scenePorts: [
|
||||
...leftTopPorts.map((p) => ({ ...p, nodeId: leftTop.fitting.id })),
|
||||
...rightTopPorts.map((p) => ({ ...p, nodeId: rightTop.fitting.id })),
|
||||
...leftBottomPorts.map((p) => ({ ...p, nodeId: leftBottom.fitting.id })),
|
||||
...rightBottomPorts.map((p) => ({ ...p, nodeId: rightBottom.fitting.id })),
|
||||
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
|
||||
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
|
||||
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
|
||||
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
|
||||
],
|
||||
nodesById: {
|
||||
[topRun.id]: topRun as AnyNode,
|
||||
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
|
||||
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
|
||||
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
|
||||
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
|
||||
[leftRiser.id]: leftRiser as AnyNode,
|
||||
[rightRiser.id]: rightRiser as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.fittings).toHaveLength(0)
|
||||
expect(result.plan.risers).toHaveLength(0)
|
||||
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
|
||||
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
|
||||
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
|
||||
expect(result.plan.updates.some((u) => u.id === leftBottom.fitting.id)).toBe(true)
|
||||
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(result.plan.ductPath[1]?.[1] ?? 999, 6)
|
||||
expect(result.plan.followPath[0]?.[1]).toBeCloseTo(topRun.path[0]![1], 6)
|
||||
expect(result.plan.followPath[1]?.[1]).toBeCloseTo(0, 6)
|
||||
})
|
||||
|
||||
test('collapses a manually height-edited side when that side aligns', () => {
|
||||
const leftBottom = planElbowAtPort(portLike([0, 0.5, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
||||
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
||||
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
|
||||
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
|
||||
|
||||
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
|
||||
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
|
||||
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
|
||||
const result = planVerticalOffsets({
|
||||
duct: topRun,
|
||||
dy: -0.7,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [
|
||||
fittingConnection(leftTop.fitting),
|
||||
fittingConnection(rightTop.fitting),
|
||||
runConnection(leftRiser),
|
||||
runConnection(rightRiser),
|
||||
fittingConnection(leftBottom.fitting),
|
||||
fittingConnection(rightBottom.fitting),
|
||||
],
|
||||
scenePorts: [
|
||||
...getDuctFittingPorts(leftTop.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: leftTop.fitting.id,
|
||||
})),
|
||||
...getDuctFittingPorts(rightTop.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: rightTop.fitting.id,
|
||||
})),
|
||||
...getDuctFittingPorts(leftBottom.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: leftBottom.fitting.id,
|
||||
})),
|
||||
...getDuctFittingPorts(rightBottom.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: rightBottom.fitting.id,
|
||||
})),
|
||||
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
|
||||
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
|
||||
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
|
||||
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
|
||||
],
|
||||
nodesById: {
|
||||
[topRun.id]: topRun as AnyNode,
|
||||
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
|
||||
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
|
||||
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
|
||||
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
|
||||
[leftRiser.id]: leftRiser as AnyNode,
|
||||
[rightRiser.id]: rightRiser as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
|
||||
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
|
||||
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
|
||||
expect(result.plan.updates.some((u) => u.id === leftBottom.fitting.id)).toBe(true)
|
||||
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(0.5, 6)
|
||||
expect(result.plan.ductPath[1]?.[1]).toBeCloseTo(0.5, 6)
|
||||
})
|
||||
|
||||
test('continues past one unequal side without snapping to the lower side early', () => {
|
||||
const leftBottom = planElbowAtPort(portLike([0, 0.5, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
||||
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
||||
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
|
||||
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
|
||||
|
||||
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
|
||||
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
|
||||
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
|
||||
const result = planVerticalOffsets({
|
||||
duct: topRun,
|
||||
dy: -1.8,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [
|
||||
fittingConnection(leftTop.fitting),
|
||||
fittingConnection(rightTop.fitting),
|
||||
runConnection(leftRiser),
|
||||
runConnection(rightRiser),
|
||||
fittingConnection(leftBottom.fitting),
|
||||
fittingConnection(rightBottom.fitting),
|
||||
],
|
||||
scenePorts: [
|
||||
...getDuctFittingPorts(leftTop.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: leftTop.fitting.id,
|
||||
})),
|
||||
...getDuctFittingPorts(rightTop.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: rightTop.fitting.id,
|
||||
})),
|
||||
...getDuctFittingPorts(leftBottom.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: leftBottom.fitting.id,
|
||||
})),
|
||||
...getDuctFittingPorts(rightBottom.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: rightBottom.fitting.id,
|
||||
})),
|
||||
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
|
||||
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
|
||||
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
|
||||
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
|
||||
],
|
||||
nodesById: {
|
||||
[topRun.id]: topRun as AnyNode,
|
||||
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
|
||||
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
|
||||
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
|
||||
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
|
||||
[leftRiser.id]: leftRiser as AnyNode,
|
||||
[rightRiser.id]: rightRiser as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.dy).toBeCloseTo(-1.8, 6)
|
||||
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(-0.6, 6)
|
||||
expect(result.plan.ductPath[1]?.[1]).toBeCloseTo(-0.6, 6)
|
||||
expect(result.plan.fittings).toHaveLength(1)
|
||||
expect(result.plan.risers).toHaveLength(1)
|
||||
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
|
||||
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
|
||||
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
|
||||
})
|
||||
|
||||
test('consumes multiple side alignments during one continuous drag', () => {
|
||||
const leftBottom = planElbowAtPort(portLike([0, 0.5, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
||||
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
||||
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
|
||||
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
|
||||
|
||||
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
|
||||
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
|
||||
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
|
||||
const dy = -3.1
|
||||
const result = planVerticalOffsets({
|
||||
duct: topRun,
|
||||
dy,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [
|
||||
fittingConnection(leftTop.fitting),
|
||||
fittingConnection(rightTop.fitting),
|
||||
runConnection(leftRiser),
|
||||
runConnection(rightRiser),
|
||||
fittingConnection(leftBottom.fitting),
|
||||
fittingConnection(rightBottom.fitting),
|
||||
],
|
||||
scenePorts: [
|
||||
...getDuctFittingPorts(leftTop.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: leftTop.fitting.id,
|
||||
})),
|
||||
...getDuctFittingPorts(rightTop.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: rightTop.fitting.id,
|
||||
})),
|
||||
...getDuctFittingPorts(leftBottom.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: leftBottom.fitting.id,
|
||||
})),
|
||||
...getDuctFittingPorts(rightBottom.fitting).map((p) => ({
|
||||
...p,
|
||||
nodeId: rightBottom.fitting.id,
|
||||
})),
|
||||
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
|
||||
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
|
||||
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
|
||||
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
|
||||
],
|
||||
nodesById: {
|
||||
[topRun.id]: topRun as AnyNode,
|
||||
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
|
||||
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
|
||||
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
|
||||
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
|
||||
[leftRiser.id]: leftRiser as AnyNode,
|
||||
[rightRiser.id]: rightRiser as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.dy).toBeCloseTo(dy, 6)
|
||||
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(topRun.path[0]![1] + dy, 6)
|
||||
expect(result.plan.ductPath[1]?.[1]).toBeCloseTo(topRun.path[1]![1] + dy, 6)
|
||||
expect(result.plan.delete).toEqual(
|
||||
expect.arrayContaining([
|
||||
leftTop.fitting.id,
|
||||
leftRiser.id,
|
||||
rightTop.fitting.id,
|
||||
rightRiser.id,
|
||||
]),
|
||||
)
|
||||
expect(result.plan.updates.some((u) => u.id === leftBottom.fitting.id)).toBe(true)
|
||||
expect(result.plan.updates.some((u) => u.id === rightBottom.fitting.id)).toBe(true)
|
||||
})
|
||||
|
||||
test('snaps downward through the short-riser dead band into the collapse route', () => {
|
||||
const leftBottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
||||
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
||||
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
|
||||
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
|
||||
|
||||
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
|
||||
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
|
||||
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
|
||||
const leftBottomPorts = getDuctFittingPorts(leftBottom.fitting)
|
||||
const leftTopPorts = getDuctFittingPorts(leftTop.fitting)
|
||||
const rightBottomPorts = getDuctFittingPorts(rightBottom.fitting)
|
||||
const rightTopPorts = getDuctFittingPorts(rightTop.fitting)
|
||||
const connections = [
|
||||
fittingConnection(leftTop.fitting),
|
||||
fittingConnection(rightTop.fitting),
|
||||
runConnection(leftRiser),
|
||||
runConnection(rightRiser),
|
||||
fittingConnection(leftBottom.fitting),
|
||||
fittingConnection(rightBottom.fitting),
|
||||
]
|
||||
const scenePorts = [
|
||||
...leftTopPorts.map((p) => ({ ...p, nodeId: leftTop.fitting.id })),
|
||||
...rightTopPorts.map((p) => ({ ...p, nodeId: rightTop.fitting.id })),
|
||||
...leftBottomPorts.map((p) => ({ ...p, nodeId: leftBottom.fitting.id })),
|
||||
...rightBottomPorts.map((p) => ({ ...p, nodeId: rightBottom.fitting.id })),
|
||||
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
|
||||
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
|
||||
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
|
||||
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
|
||||
]
|
||||
const nodesById = {
|
||||
[topRun.id]: topRun as AnyNode,
|
||||
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
|
||||
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
|
||||
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
|
||||
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
|
||||
[leftRiser.id]: leftRiser as AnyNode,
|
||||
[rightRiser.id]: rightRiser as AnyNode,
|
||||
}
|
||||
|
||||
for (const dy of [-0.4, -0.6, -0.8, -1.0, -1.1]) {
|
||||
const result = planVerticalOffsets({
|
||||
duct: topRun,
|
||||
dy,
|
||||
profile: RECT_PROFILE,
|
||||
connections,
|
||||
scenePorts,
|
||||
nodesById,
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') continue
|
||||
expect(result.plan.dy).toBeCloseTo(-1.2, 6)
|
||||
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
|
||||
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
|
||||
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
|
||||
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(result.plan.ductPath[1]?.[1] ?? 999, 6)
|
||||
}
|
||||
})
|
||||
|
||||
test('collapses a direct vertical riser when the moved run passes the lower elbow', () => {
|
||||
const bottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
expect(bottom).toBeTruthy()
|
||||
if (!bottom) return
|
||||
|
||||
const bottomPorts = getDuctFittingPorts(bottom.fitting)
|
||||
const verticalPort = bottomPorts.find((p) => distSq(p.position, bottom.collarPoint) < 1e-9)!
|
||||
const riserTop: Point = [bottom.collarPoint[0], 1.2, bottom.collarPoint[2]]
|
||||
const riser = rectRun([bottom.collarPoint, riserTop])
|
||||
const topRun = rectRun([riserTop, [4, riserTop[1], riserTop[2]]])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
duct: topRun,
|
||||
dy: -0.8,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [runConnection(riser), fittingConnection(bottom.fitting)],
|
||||
scenePorts: [
|
||||
...bottomPorts.map((p) => ({ ...p, nodeId: bottom.fitting.id })),
|
||||
runPort(riser, bottom.collarPoint, [0, -1, 0]),
|
||||
runPort(riser, riserTop, [0, 1, 0]),
|
||||
],
|
||||
nodesById: {
|
||||
[topRun.id]: topRun as AnyNode,
|
||||
[riser.id]: riser as AnyNode,
|
||||
[bottom.fitting.id]: bottom.fitting as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.dy).toBeCloseTo(-1.2, 6)
|
||||
expect(result.plan.fittings).toHaveLength(0)
|
||||
expect(result.plan.risers).toHaveLength(0)
|
||||
expect(result.plan.delete).toEqual(expect.arrayContaining([riser.id]))
|
||||
expect(result.plan.updates.some((u) => u.id === bottom.fitting.id)).toBe(true)
|
||||
const bottomUpdate = result.plan.updates.find((u) => u.id === bottom.fitting.id)
|
||||
const reaimedBottom = DuctFittingNode.parse({ ...bottom.fitting, ...bottomUpdate?.data })
|
||||
const reaimedPorts = getDuctFittingPorts(reaimedBottom)
|
||||
expect(reaimedPorts.some((p) => distSq(p.position, result.plan.ductPath[0]!) < 1e-9)).toBe(true)
|
||||
expect(verticalPort).toBeDefined()
|
||||
})
|
||||
|
||||
test('continues routing after a collapse without needing a new drag', () => {
|
||||
const bottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
||||
expect(bottom).toBeTruthy()
|
||||
if (!bottom) return
|
||||
|
||||
const bottomPorts = getDuctFittingPorts(bottom.fitting)
|
||||
const riserTop: Point = [bottom.collarPoint[0], 1.2, bottom.collarPoint[2]]
|
||||
const riser = rectRun([bottom.collarPoint, riserTop])
|
||||
const topRun = rectRun([riserTop, [4, riserTop[1], riserTop[2]]])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
duct: topRun,
|
||||
dy: -2.4,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [runConnection(riser), fittingConnection(bottom.fitting)],
|
||||
scenePorts: [
|
||||
...bottomPorts.map((p) => ({ ...p, nodeId: bottom.fitting.id })),
|
||||
runPort(riser, bottom.collarPoint, [0, -1, 0]),
|
||||
runPort(riser, riserTop, [0, 1, 0]),
|
||||
],
|
||||
nodesById: {
|
||||
[topRun.id]: topRun as AnyNode,
|
||||
[riser.id]: riser as AnyNode,
|
||||
[bottom.fitting.id]: bottom.fitting as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('valid')
|
||||
if (result?.status !== 'valid') return
|
||||
expect(result.plan.dy).toBeCloseTo(-2.4, 6)
|
||||
expect(result.plan.delete).toEqual(expect.arrayContaining([riser.id]))
|
||||
expect(result.plan.fittings.length).toBeGreaterThan(0)
|
||||
expect(result.plan.risers.length).toBeGreaterThan(0)
|
||||
expect(result.plan.ductPath[0]?.[1]).toBeLessThan(0)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ label: 'collapse', dy: 1 },
|
||||
{ label: 'cross', dy: 1.2 },
|
||||
])('does not $label an existing vertical riser while stretching it', ({ dy }) => {
|
||||
const moved = rectRun([
|
||||
[0, 0, 0],
|
||||
[4, 0, 0],
|
||||
])
|
||||
const riser = rectRun([
|
||||
[0, 0, 0],
|
||||
[0, 1, 0],
|
||||
])
|
||||
|
||||
const result = planVerticalOffsets({
|
||||
duct: moved,
|
||||
dy,
|
||||
profile: RECT_PROFILE,
|
||||
connections: [runConnection(riser)],
|
||||
scenePorts: [runPort(riser, [0, 0, 0], [0, -1, 0])],
|
||||
nodesById: {
|
||||
[moved.id]: moved as AnyNode,
|
||||
[riser.id]: riser as AnyNode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.status).toBe('invalid')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,14 @@ import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
snapRelativeRoofDragTarget,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfaceNodePlacementGuides,
|
||||
snapRoofSurfaceNodeTarget,
|
||||
} from '../shared/roof-surface-placement-guides'
|
||||
import SkylightPreview from './preview'
|
||||
|
||||
export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
@@ -76,6 +82,17 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
setHasHit(false)
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
|
||||
const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => {
|
||||
const rawTarget = roofDrag.resolve(event)
|
||||
if (!rawTarget) return null
|
||||
return snapRoofSurfaceNodeTarget({
|
||||
target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true),
|
||||
node,
|
||||
bypass: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve which segment the cursor is over, then derive the same
|
||||
@@ -86,7 +103,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
// same via its `if (!hit) return` guard.
|
||||
const updateFromHit = (event: RoofEvent) => {
|
||||
const roof = event.node as RoofNode
|
||||
const target = roofDrag.resolve(event)
|
||||
const target = resolveSnappedTarget(event)
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return false
|
||||
@@ -103,6 +120,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
]),
|
||||
)
|
||||
setHasHit(true)
|
||||
publishRoofSurfaceNodePlacementGuides({
|
||||
roof,
|
||||
segment: target.segment,
|
||||
center: [target.localX, target.localY, target.localZ],
|
||||
node,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -127,7 +150,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
if (committed) return
|
||||
const st = useScene.getState()
|
||||
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
|
||||
@@ -176,6 +199,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
if (obj) obj.visible = true
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -196,6 +220,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
}
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
@@ -216,6 +241,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
@@ -245,6 +271,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
clearRoofSurfacePlacementGuides()
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
type SolarPanelNode,
|
||||
sceneRegistry,
|
||||
@@ -22,8 +23,14 @@ import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
snapRelativeRoofDragTarget,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfaceNodePlacementGuides,
|
||||
snapRoofSurfaceNodeTarget,
|
||||
} from '../shared/roof-surface-placement-guides'
|
||||
|
||||
// MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding
|
||||
// fragment stage output / writeMask not zero" error that fires when
|
||||
@@ -103,10 +110,21 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
const clearTarget = () => {
|
||||
lastTarget = null
|
||||
setHasHit(false)
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
|
||||
const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => {
|
||||
const rawTarget = roofDrag.resolve(event)
|
||||
if (!rawTarget) return null
|
||||
return snapRoofSurfaceNodeTarget({
|
||||
target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true),
|
||||
node,
|
||||
bypass: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
}
|
||||
|
||||
const updateGhost = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
const target = resolveSnappedTarget(event)
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
@@ -138,6 +156,12 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
]),
|
||||
)
|
||||
setHasHit(true)
|
||||
publishRoofSurfaceNodePlacementGuides({
|
||||
roof: event.node as RoofNode,
|
||||
segment: target.segment,
|
||||
center: [target.localX, target.localY, target.localZ],
|
||||
node,
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
@@ -145,7 +169,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
if (committed) return
|
||||
const st = useScene.getState()
|
||||
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
|
||||
@@ -201,6 +225,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
if (obj) obj.visible = true
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -221,6 +246,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
}
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
@@ -241,6 +267,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
@@ -270,6 +297,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
clearRoofSurfacePlacementGuides()
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -45,7 +45,7 @@ function previewStairSlot(args: PaintPreviewArgs): (() => void) | null {
|
||||
}
|
||||
|
||||
if (!Array.isArray(userData.slotIds)) return
|
||||
const materialIndex = userData.slotIds.findIndex((slotId) => slotId === role)
|
||||
const materialIndex = userData.slotIds.indexOf(role)
|
||||
if (materialIndex < 0) return
|
||||
if (!Array.isArray(mesh.material)) return
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
type TurbineVentNode,
|
||||
@@ -21,8 +22,14 @@ import {
|
||||
createRelativeRoofDrag,
|
||||
type RelativeRoofDragTarget,
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
snapRelativeRoofDragTarget,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfaceNodePlacementGuides,
|
||||
snapRoofSurfaceNodeTarget,
|
||||
} from '../shared/roof-surface-placement-guides'
|
||||
import TurbineVentPreview from './preview'
|
||||
|
||||
/**
|
||||
@@ -71,10 +78,21 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
lastSnap = null
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
|
||||
const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => {
|
||||
const rawTarget = roofDrag.resolve(event)
|
||||
if (!rawTarget) return null
|
||||
return snapRoofSurfaceNodeTarget({
|
||||
target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true),
|
||||
node,
|
||||
bypass: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const target = roofDrag.resolve(event)
|
||||
const target = resolveSnappedTarget(event)
|
||||
if (!target) {
|
||||
clearTarget()
|
||||
return
|
||||
@@ -101,12 +119,18 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
target.localZ,
|
||||
]),
|
||||
)
|
||||
publishRoofSurfaceNodePlacementGuides({
|
||||
roof: event.node as RoofNode,
|
||||
segment: target.segment,
|
||||
center: [target.localX, target.localY, target.localZ],
|
||||
node,
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = lastTarget ?? roofDrag.resolve(event)
|
||||
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||
if (!target) return
|
||||
committed = true
|
||||
const targetSegmentId = target.segment.id as AnyNodeId
|
||||
@@ -147,6 +171,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
if (obj) obj.visible = true
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -165,6 +190,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
@@ -184,6 +210,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
@@ -213,6 +240,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
clearRoofSurfacePlacementGuides()
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -43,6 +43,9 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor<WindowNodeT
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
// Stand the blade up into the wall face so it reads face-on from the
|
||||
// front instead of edge-on (the window sits on a vertical wall).
|
||||
faceNormal: true,
|
||||
anchor: side === 'right' ? 'min' : 'max',
|
||||
min: MIN_WINDOW_WIDTH,
|
||||
max: (n, scene) => {
|
||||
|
||||
@@ -29,16 +29,7 @@ const WindowPreview = ({
|
||||
const m = buildWindowPreviewMesh(node)
|
||||
m.layers.set(EDITOR_LAYER)
|
||||
return m
|
||||
}, [
|
||||
node.width,
|
||||
node.height,
|
||||
node.frameDepth,
|
||||
node.openingShape,
|
||||
node.windowType,
|
||||
node.sill,
|
||||
node.sillDepth,
|
||||
node.sillThickness,
|
||||
])
|
||||
}, [node])
|
||||
|
||||
// Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips;
|
||||
// its cleanup only disposes the clones it made.
|
||||
|
||||
Reference in New Issue
Block a user