Merge origin/main into feat/placement-interaction-overhaul
Resolve 7 conflicts keeping our snapping migration + floorplan perf work as source of truth, combined with main's MEP run-continuation / Alt-detach / latch handles. Rebuilt two import blocks the auto-merge silently truncated (node-arrow-handles.tsx, duct-fitting/move-tool.tsx). Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass, biome clean. Floorplan view-transform re-render storm confirmed pre-existing (not introduced by this merge). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --build --watch",
|
||||
"dev": "tsgo --build --watch",
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "bun run build && bun test"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
@@ -30,16 +31,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',
|
||||
}),
|
||||
@@ -52,6 +53,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,
|
||||
@@ -76,6 +79,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,
|
||||
isGridSnapActive,
|
||||
@@ -24,11 +25,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]
|
||||
@@ -176,9 +179,24 @@ 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) => {
|
||||
// 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 = isGridSnapActive() ? snapToGridStep : (v: number) => v
|
||||
let x = snap(event.localPosition[0])
|
||||
let z = snap(event.localPosition[2])
|
||||
@@ -200,21 +218,29 @@ 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 (
|
||||
(isGridSnapActive() || isMagneticSnapActive()) &&
|
||||
(next[0] !== lastPos[0] || 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
|
||||
}
|
||||
@@ -236,10 +262,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()
|
||||
@@ -251,6 +291,7 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -262,14 +303,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'
|
||||
@@ -75,6 +77,8 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
slots: () => ductBodySlots(),
|
||||
paint: ductBodyPaint,
|
||||
},
|
||||
|
||||
parametrics: ductSegmentParametrics,
|
||||
@@ -107,6 +111,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
|
||||
@@ -151,6 +156,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).
|
||||
@@ -168,7 +176,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: 'Alt + drag', label: 'Go vertical ↕, click to place' },
|
||||
{ key: '[ / ]', label: 'Duct diameter down / up' },
|
||||
{ key: 'Q', label: 'Round / rect trunk' },
|
||||
|
||||
@@ -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,
|
||||
isGridSnapActive,
|
||||
@@ -29,6 +31,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]
|
||||
@@ -108,6 +117,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())
|
||||
@@ -140,6 +150,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)
|
||||
@@ -179,12 +209,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
|
||||
}
|
||||
@@ -207,10 +255,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()
|
||||
@@ -222,6 +304,8 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
setTranslationGhost(null)
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -233,14 +317,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()
|
||||
@@ -263,6 +370,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 {
|
||||
@@ -22,8 +24,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,
|
||||
@@ -33,6 +44,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,
|
||||
@@ -43,17 +55,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
|
||||
@@ -73,9 +85,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
|
||||
@@ -98,6 +111,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
|
||||
@@ -167,6 +185,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
|
||||
@@ -263,6 +289,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)
|
||||
@@ -289,12 +518,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)
|
||||
@@ -321,246 +562,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)
|
||||
@@ -578,11 +636,11 @@ const DuctSegmentTool = () => {
|
||||
// every snapping mode except `off` (the raw-cursor bypass).
|
||||
const snapEnabled = isGridSnapActive() || isMagneticSnapActive() || isAngleSnapActive()
|
||||
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,
|
||||
@@ -605,15 +663,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` in `angles` mode.
|
||||
// Y stays at `last[1]` — depth changes come from Alt-vertical 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-vertical risers.
|
||||
const rawXZ: [number, number, number] = [
|
||||
event.localPosition[0],
|
||||
last[1],
|
||||
@@ -643,8 +706,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,
|
||||
@@ -685,6 +751,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
|
||||
@@ -695,12 +772,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) => {
|
||||
@@ -787,12 +868,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')
|
||||
}
|
||||
}
|
||||
@@ -811,6 +894,8 @@ const DuctSegmentTool = () => {
|
||||
setDraftPoints([])
|
||||
setCursorPos(null)
|
||||
setSnapTarget(null)
|
||||
setEndSnap({ port: null, body: null })
|
||||
setHoverCeiling(null)
|
||||
startPortRef.current = null
|
||||
startBodyRef.current = null
|
||||
}
|
||||
@@ -842,6 +927,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
|
||||
@@ -872,15 +973,50 @@ 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
|
||||
@@ -889,28 +1025,19 @@ const DuctSegmentTool = () => {
|
||||
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}>
|
||||
@@ -923,25 +1050,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)
|
||||
@@ -963,7 +1177,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 && (
|
||||
|
||||
@@ -560,6 +560,7 @@ export const FenceTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!previewRef.current) return
|
||||
if (buildingState.current === 1 && event.nativeEvent.detail >= 2) {
|
||||
stopDrafting()
|
||||
return
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type Interactive,
|
||||
type ItemNode,
|
||||
isSlotMaterialName,
|
||||
itemClipRegistry,
|
||||
LIBRARY_MATERIAL_REF_PREFIX,
|
||||
type LightEffect,
|
||||
SCENE_MATERIAL_REF_PREFIX,
|
||||
@@ -379,7 +380,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 =
|
||||
@@ -387,6 +388,20 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
const lightEffects =
|
||||
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
|
||||
|
||||
// Expose this item's ambient clip (e.g. a fan's spin) to the GLB bake. The
|
||||
// catalog GLB owns the clip; it isn't in the scene graph, so the export can't
|
||||
// find it without this registry. The bake retargets it onto the baked subtree.
|
||||
useEffect(() => {
|
||||
if (!animEffect) return
|
||||
const clipName = animEffect.clips.on ?? animEffect.clips.loop
|
||||
const clip = clipName ? animations.find((c) => c.name === clipName) : undefined
|
||||
if (!clip) return
|
||||
itemClipRegistry.set(node.id, { clip, loop: true })
|
||||
return () => {
|
||||
itemClipRegistry.delete(node.id)
|
||||
}
|
||||
}, [node.id, animEffect, animations])
|
||||
|
||||
// useGLTF caches scenes, and Clone shares child geometry/material references.
|
||||
// Undo can unmount one item while another clone of the same asset still needs them.
|
||||
return (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
collectGhostAlignmentCandidates,
|
||||
resolveGhostAlignment,
|
||||
} from '../shared/ghost-alignment'
|
||||
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
|
||||
@@ -139,6 +140,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)
|
||||
@@ -178,7 +185,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) => {
|
||||
@@ -206,10 +215,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()
|
||||
@@ -221,6 +241,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -240,6 +261,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,
|
||||
@@ -19,18 +19,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 follows the active snapping mode: `angles` locks it to
|
||||
* the nearest 45° step in XZ from the start (Y stays at the start's
|
||||
* height); `grid`/`lines`/`off` leave it free. Shift cycles the mode.
|
||||
@@ -108,32 +108,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')
|
||||
|
||||
@@ -27,18 +27,18 @@ 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
|
||||
* follows the active snapping mode (`angles` locks it to 45°; Shift cycles
|
||||
* the snapping mode), Alt drags it vertical.
|
||||
* - **Second click** commits a two-point line and keeps its far end anchored,
|
||||
* so the next click continues the run; the in-flight end follows the active
|
||||
* snapping mode (`angles` locks it to 45°; Shift cycles the mode), 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
|
||||
@@ -121,46 +121,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
|
||||
}
|
||||
|
||||
@@ -203,47 +295,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
|
||||
@@ -478,7 +562,7 @@ const LiquidLineTool = () => {
|
||||
}}
|
||||
>
|
||||
{followTargetRef.current
|
||||
? 'Click to trace this lineset'
|
||||
? 'Click to trace this lineset run'
|
||||
: 'Follow: hover a lineset'}
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
@@ -79,6 +79,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
|
||||
|
||||
@@ -29,6 +29,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 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)
|
||||
@@ -176,7 +183,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) => {
|
||||
@@ -204,10 +213,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()
|
||||
@@ -219,6 +239,7 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -238,6 +259,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
@@ -59,6 +59,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). */
|
||||
@@ -92,6 +97,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],
|
||||
@@ -309,11 +336,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)
|
||||
}
|
||||
@@ -483,6 +513,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
|
||||
}
|
||||
@@ -617,7 +655,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
|
||||
|
||||
@@ -27,7 +27,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
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,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