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:
Wassim SAMAD
2026-06-28 16:22:47 -04:00
co-authored by Claude Opus 4.8
171 changed files with 19294 additions and 2224 deletions
@@ -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([])
})
})
+57 -2
View File
@@ -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',
+765 -19
View File
@@ -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