nodes: add MEP movement controls and DWV parity (#438)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* feat(duct): ceiling-snap drawing + connected-joint endpoint move

Duct draw tool's ceiling mode now hangs each path point just below the
ceiling actually covering it (per-room heights tracked), with a
translucent surface highlight and a plumb line to the floor so the
in-flight point reads clearly from any angle.

Dragging a duct corner that sits on a fitting now carries the fitting's
other ducts along (port-connectivity second hop), so the joint moves
together instead of tearing apart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): detach + vertical modifiers for duct/pipe joint editing

Alt detaches a dragged duct/pipe endpoint or fitting from its connected
joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical
riser movement on the fitting move. Behavioral parity across 2D and 3D.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): full DWV pipe parity for joint editing

Bring pipe-segment endpoint drags and pipe-fitting moves to parity with
duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow
re-aim, and connectivity follow. Generalizes the shared elbow-reaim and
auto-fitting helpers to dispatch by run kind so 2D and 3D share one path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): wall-style arrow handles for duct fittings + segments

Add violet directional arrow affordances to duct-fitting selection (height,
move cross, rotate arc) mirroring the duct-segment rig: portaled into the
parent frame to stay out of the selection outline, rendered via the shared
HandleArrow, and carrying mated-run connectivity through the single-undo
dance. The move cross engages press-drag-release (placementDragMode) the same
way the floating drag does, so the markup hit-areas go inert and the fitting
move tool commits on pointer-up.

Also re-export the HandleArrow primitives from @pascal-app/editor and extend
the duct-segment side-move/floorplan affordances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): click-to-latch cube handles for duct + fitting editing

Replace the hover-reveal / multi-handle selection rigs with a single
click-to-latch cube that opens a directional cluster, shared between
duct segments and fittings via a new selection-handles module
(HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube).

- Duct segment: per-vertex + run-center cubes reveal axis-locked move
  chevrons (down arrow always shown), plus a roll arc at the run center.
- Duct fitting: center cube reveals six ±XYZ move arrows and three
  per-axis rotation arcs (oriented in place), replacing the old
  height/move/rotate trio with axis-cycling.
- Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth.
- thin chevron profile + press-drag-release commit retained.

* fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag

Build a fully-determined basis for the duct roll gizmo so the curved
arrow always seats at the top-outer 45° corner regardless of run
direction, instead of an arbitrary apex from a single setFromUnitVectors.
The selection-rig ±Y arrows now own vertical movement, so the redundant
Ctrl-modifier riser drag is removed from the fitting move tool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): run-aligned duct handles, swing snapping, elbow flatten

Align the duct run-center cube + horizontal arrows to the run axis
(matching the per-vertex handles) while keeping whole-run translate.
Endpoint side / up-down swing arrows now follow grid snap points and
port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow
realign + fitting schemas to flatten to a straight 0° coupling. Surface
HVAC-specific hints in the select-mode helper panel for duct / fitting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): per-segment linesets/liquid-lines with joint-follow editing

Linesets and liquid lines now commit one independent two-point node per
drawn segment instead of folding into a single mitered polyline, so each
line selects and deletes on its own. Endpoint caps fill shared-coordinate
joints so connected segments still read as continuous pipe.

Dragging a shared endpoint carries mated segments along via port
connectivity (Alt detaches), so a run still edits as one welded piece.
Liquid-line follow mode traces the whole connected lineset run, laying a
per-segment parallel line down its full assembled length.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag

Lifting/lowering a connected run with the run-center cube now keeps each
connected end welded to its stationary partner instead of dragging the
whole network. Run-to-run ends get the classic S/Z offset (two elbows +
plumb riser, partner trimmed back one leg); elbow-connected ends form a
clean L — the existing elbow stays put and re-aims its collar vertical,
with one new top elbow + riser reconnecting to the lifted endpoint. The
offset is ghosted live and minted as a single undo step on release.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add roof accessory placement guides

Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters.

* Improve duct and placement routing

* Fix duct vertical movement routing

* Fix duct vertical offsets and roof accessory movement

* Add DWV movement parity and line endpoint controls

* Fix MEP handle review issues

* Fix chimney placement and duct offset cleanup

* Use snapped targets for roof accessory commits

* fix(nodes): repair MEP movement review issues

- auto-fitting: tee branch now follows the drawn lateral angle; update the
  stale square-tee test + doc comment that contradicted the rewrite
- duct-segment: re-enable the vertical auto-offset rewind (the disabled stub
  left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers
  and stacked duplicates); remove the dead stub
- duct-segment: strip the stale auto-offset tag on manual corner/roll commits
  so the horizontal-move path no longer trusts an out-of-date base
- chimney: resume history before mutating segment children arrays so a
  cross-segment move reparents in one tracked transaction (undo stays consistent)
- dormer: align schema test with the new windowSill=false default

* fix: address mep movement review issues

* fix: address follow-up mep review comments

* fix: address additional mep review comments

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pascal-open-bot <open@pascal.app>
This commit is contained in:
Sudhir Yadav
2026-06-23 08:37:15 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 pascal-open-bot
parent ae90da6554
commit a71de82ccb
129 changed files with 15799 additions and 2049 deletions
@@ -1,4 +1,5 @@
import type { NodeDefinition } from '@pascal-app/core'
import { ductBodyPaint, ductBodySlots } from '../shared/duct-body-paint'
import { rotateFittingNode } from '../shared/fitting-rotation'
import { buildDuctFittingFloorplan } from './floorplan'
import { buildDuctFittingGeometry } from './geometry'
@@ -29,16 +30,16 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
position: [0, 0, 0],
rotation: [0, 0, 0],
fittingType: 'elbow',
shape: 'round',
shape: 'rect',
width: 14,
height: 8,
shape2: 'round',
shape2: 'rect',
width2: 14,
height2: 8,
angle: 90,
branchAngle: 90,
diameter: 6,
diameter2: 6,
diameter: 12,
diameter2: 12,
ductMaterial: 'sheet-metal',
system: 'supply',
}),
@@ -51,6 +52,8 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
duplicable: true,
deletable: true,
slots: () => ductBodySlots(),
paint: ductBodyPaint,
},
parametrics: ductFittingParametrics,
@@ -75,6 +78,7 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
n.diameter2,
n.ductMaterial,
n.system,
n.slots,
]),
ports: getDuctFittingPorts,
+27 -5
View File
@@ -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>
)
}
+75 -6
View File
@@ -11,6 +11,7 @@ import {
useScene,
} from '@pascal-app/core'
import {
consumePlacementDragRelease,
DragBoundingBox,
EDITOR_LAYER,
markToolCancelConsumed,
@@ -22,11 +23,13 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react'
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
import { autoOffsetInvalidationUpdates } from '../shared/auto-offset-tag'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
import { buildDuctFittingGeometry } from './geometry'
type Vec3 = [number, number, number]
@@ -174,11 +177,27 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
if (existedAtStart) setMeshHidden(true)
// Carry connected ducts as the fitting slides: the part of the move along
// a run's axis stretches it, the part across translates the whole run (and
// propagates to its far joint). Snapshot once at drag start; only existing
// fittings are mated to anything.
const connectivity: RunMoveConnectivity | null = existedAtStart
? startRunMoveConnectivity(node)
: null
let lastPos: Vec3 = originalPosition
// Tracks whether the last frame held Alt: the fitting is detached from its
// connected ducts for the drag, so they stay put (no follow) and the
// commit omits their updates. Mirrors the duct endpoint's Alt-detach.
let lastDetached = false
const onMove = (event: GridEvent) => {
const bypass = event.nativeEvent?.shiftKey === true
// Alt = detach: drop the connected-duct follow so the fitting moves on
// its own, leaving every mated run where it sits.
const detached = event.nativeEvent?.altKey === true
const snap = bypass ? (v: number) => v : snapToGridStep
let x = snap(event.localPosition[0])
let z = snap(event.localPosition[2])
@@ -198,17 +217,27 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
} else {
useAlignmentGuides.getState().clear()
}
const next: Vec3 = [x, lastPos[1], z]
const next: Vec3 = [x, originalPosition[1], z]
if (next[0] !== lastPos[0] || next[2] !== lastPos[2]) triggerSFX('sfx:grid-snap')
if (next[0] !== lastPos[0] || next[1] !== lastPos[1] || next[2] !== lastPos[2]) {
triggerSFX('sfx:grid-snap')
}
lastPos = next
lastDetached = detached
hasMoved = true
setCursorPos(next)
// Detached: keep the followers at their origin (drop any live overrides
// from a prior non-detached frame). Otherwise preview the follow.
if (detached) connectivity?.clear()
else connectivity?.preview({ position: next })
}
const commit = (event: GridEvent) => {
const commit = (event: GridEvent, fromDragRelease = false) => {
if (committed) return
if (Date.now() - activatedAt < 150) {
// The 150ms debounce only guards click-to-place against the arming click
// double-firing; a press-drag release is a distinct pointerup gesture, so
// it skips the guard (a quick drag-flick still commits).
if (!fromDragRelease && Date.now() - activatedAt < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
@@ -230,10 +259,24 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
useScene.getState().updateNode(nodeId, { position: lastPos } as Partial<AnyNode>)
useScene.getState().markDirty(nodeId)
// Fold connected-duct / sibling-run follow-updates into the SAME batch
// as the moved fitting so the whole joint is one undo step. Detached
// (Alt on the final frame): the joint is broken, so nothing follows.
const followUpdates = lastDetached
? []
: (connectivity?.commitUpdates({ position: lastPos }) ?? [])
const scene = useScene.getState()
scene.updateNodes([
{ id: nodeId, data: { position: lastPos } as Partial<AnyNode> },
...followUpdates,
...autoOffsetInvalidationUpdates(scene.nodes, nodeId),
])
scene.markDirty(nodeId)
}
useScene.temporal.getState().pause()
// Followers are committed to the store — drop their live overrides so
// renderers read the canonical path/position.
connectivity?.clear()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
@@ -245,6 +288,7 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
const onCancel = () => {
connectivity?.clear()
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -256,14 +300,39 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
useEditor.getState().setMovingNode(null)
}
// Press-drag-release: when the move was engaged by the drag gesture (the
// selection rig's move cross or a future floating drag), `placementDragMode`
// is set, so commit on pointer-up at the last previewed position instead of
// waiting for a second click — same contract as every other move tool.
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
// A press-release that never moved isn't a placement — back out cleanly
// (drop the ghost, re-select the fitting) instead of leaving the tool
// armed waiting for a click.
if (!hasMoved) {
onCancel()
return
}
commit(
{
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as GridEvent,
true,
)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', commit)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
connectivity?.clear()
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
@@ -0,0 +1,271 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
DuctFittingNode,
DuctSegmentNode,
useScene,
} from '@pascal-app/core'
import { readAutoOffsetTag, withAutoOffsetTag } from '../shared/auto-offset-tag'
import { getDuctFittingPorts } from './ports'
let ductFittingParametrics: typeof import('./parametrics')['ductFittingParametrics']
type Point = [number, number, number]
function equivalentDiameterIn(widthIn: number, heightIn: number): number {
return 2 * Math.sqrt((widthIn * heightIn) / Math.PI)
}
function rectElbow() {
return DuctFittingNode.parse({
id: 'duct-fitting_resize' as AnyNodeId,
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Resize elbow',
fittingType: 'elbow',
shape: 'rect',
width: 14,
height: 8,
diameter: equivalentDiameterIn(14, 8),
diameter2: equivalentDiameterIn(14, 8),
ductMaterial: 'sheet-metal',
system: 'supply',
position: [0, 0, 0],
rotation: [0, 0, 0],
angle: 90,
})
}
function verticalRectRunFrom(point: Point, roll: number) {
return DuctSegmentNode.parse({
id: 'duct-segment_vertical' as AnyNodeId,
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Drawn vertical run',
path: [point, [point[0], point[1] + 3, point[2]]],
shape: 'rect',
width: 14,
height: 8,
diameter: equivalentDiameterIn(14, 8),
roll,
ductMaterial: 'sheet-metal',
insulationR: 0,
system: 'supply',
})
}
describe('ductFittingParametrics', () => {
beforeAll(async () => {
mock.module('@pascal-app/editor', () => ({
ActionButton: () => null,
}))
;({ ductFittingParametrics } = await import('./parametrics'))
})
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
useScene.temporal.getState().clear()
})
test('resizing a fitting retrims connected ducts without changing their roll', () => {
const fitting = rectElbow()
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
const originalRoll = 0.37
const duct = verticalRectRunFrom([...outlet.position] as Point, originalRoll)
useScene.setState({
nodes: {
[fitting.id]: fitting as AnyNode,
[duct.id]: duct as AnyNode,
},
rootNodeIds: [fitting.id, duct.id],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
const patch = { width: 20 }
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
const ductUpdate = updates.find((u) => u.id === duct.id)
expect(ductUpdate).toBeDefined()
expect((ductUpdate?.data as Partial<DuctSegmentNode>).path).toBeDefined()
expect((ductUpdate?.data as Partial<DuctSegmentNode>).roll).toBeUndefined()
})
test('resizing a fitting refreshes a connected duct auto-offset base path', () => {
const fitting = rectElbow()
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
const taggedDuct = DuctSegmentNode.parse({
...duct,
metadata: withAutoOffsetTag(duct.metadata, {
group: 'aoff_resize',
dy: 1,
minted: ['duct-fitting_minted' as AnyNodeId],
base: [{ id: duct.id, data: { path: duct.path } }],
}),
})
useScene.setState({
nodes: {
[fitting.id]: fitting as AnyNode,
[taggedDuct.id]: taggedDuct as AnyNode,
},
rootNodeIds: [fitting.id, taggedDuct.id],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
const patch = { width: 20 }
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
const ductUpdate = updates.find((u) => u.id === taggedDuct.id)
const nextOutlet = getDuctFittingPorts(next).find((p) => p.id === 'outlet')!
const nextTag = readAutoOffsetTag({ metadata: ductUpdate?.data.metadata })
const basePath = nextTag?.base.find((b) => b.id === taggedDuct.id)?.data.path as
| Point[]
| undefined
expect(basePath?.[0]).toEqual([...nextOutlet.position])
})
test('deleting an elbow re-extends mated runs back onto the junction', () => {
const fitting = rectElbow()
const ports = getDuctFittingPorts(fitting)
const outlet = ports.find((p) => p.id === 'outlet')!
const inlet = ports.find((p) => p.id === 'inlet')!
// Two runs meeting the elbow's collars — the L-shape the elbow trimmed.
const outletRun = verticalRectRunFrom([...outlet.position] as Point, 0)
const inletRun = DuctSegmentNode.parse({
...verticalRectRunFrom([...inlet.position] as Point, 0),
id: 'duct-segment_inlet' as AnyNodeId,
path: [
[...inlet.position] as Point,
[inlet.position[0] - 3, inlet.position[1], inlet.position[2]],
],
})
const nodes: Record<AnyNodeId, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[outletRun.id]: outletRun as AnyNode,
[inletRun.id]: inletRun as AnyNode,
}
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
const outletUpdate = updates.find((u) => u.id === outletRun.id)
const inletUpdate = updates.find((u) => u.id === inletRun.id)
// Both mated endpoints snap back to the junction (the original corner).
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
...fitting.position,
])
expect((inletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([...fitting.position])
})
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
const fitting = rectElbow()
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
const nearOutlet: Point = [outlet.position[0], outlet.position[1], outlet.position[2] + 0.04]
const outletRun = DuctSegmentNode.parse({
...verticalRectRunFrom(nearOutlet, 0),
id: 'duct-segment_outlet_gap' as AnyNodeId,
})
const nodes: Record<AnyNodeId, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[outletRun.id]: outletRun as AnyNode,
}
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
const outletUpdate = updates.find((u) => u.id === outletRun.id)
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
...fitting.position,
])
})
test('deleting a generated elbow clears the owner duct auto-offset tag', () => {
const fitting = rectElbow()
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
const taggedDuct = DuctSegmentNode.parse({
...duct,
metadata: withAutoOffsetTag(duct.metadata, {
group: 'aoff_deleted_elbow',
dy: 1,
minted: [fitting.id],
base: [{ id: duct.id, data: { path: duct.path } }],
}),
})
const nodes: Record<AnyNodeId, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[taggedDuct.id]: taggedDuct as AnyNode,
}
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
})
test('deleting a tee leaves mated runs untouched', () => {
const tee = DuctFittingNode.parse({ ...rectElbow(), fittingType: 'tee' })
const outlet = getDuctFittingPorts(tee).find((p) => p.id === 'outlet')!
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
const nodes: Record<AnyNodeId, AnyNode> = {
[tee.id]: tee as AnyNode,
[duct.id]: duct as AnyNode,
}
expect(ductFittingParametrics.onDelete?.(tee, nodes) ?? []).toEqual([])
})
test('resizing a generated fitting clears the owner duct auto-offset tag', () => {
const fitting = rectElbow()
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
const taggedDuct = DuctSegmentNode.parse({
...duct,
metadata: withAutoOffsetTag(duct.metadata, {
group: 'aoff_generated_fit',
dy: 1,
minted: [fitting.id],
base: [{ id: duct.id, data: { path: duct.path } }],
}),
})
useScene.setState({
nodes: {
[fitting.id]: fitting as AnyNode,
[taggedDuct.id]: taggedDuct as AnyNode,
},
rootNodeIds: [fitting.id, taggedDuct.id],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
const patch = { width: 20 }
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
})
})
+100 -35
View File
@@ -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',
+2 -1
View File
@@ -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
+862 -17
View File
@@ -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