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
@@ -78,6 +78,7 @@ export const pipeFittingDefinition: NodeDefinition<typeof PipeFittingNode> = {
// editor's SelectionAffordanceManager rather than `def.system`.
affordanceTools: {
selection: () => import('./selection'),
move: () => import('./move-tool'),
},
tool: () => import('./tool'),
@@ -0,0 +1,361 @@
'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
emitter,
type GridEvent,
PipeFittingNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
DragBoundingBox,
EDITOR_LAYER,
markToolCancelConsumed,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react'
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
import { buildPipeFittingGeometry } from './geometry'
type Vec3 = [number, number, number]
const GHOST_COLOR = '#818cf8'
const GHOST_OPACITY = 0.5
/** Screen pixels → meters for the Ctrl-vertical (riser) drag — matches the
* pipe draw tool's Alt-vertical feel. 100 px ≈ 1 m. */
const VERTICAL_PIXELS_PER_METER = 100
const VERTICAL_Y_MIN_M = -3
const VERTICAL_Y_MAX_M = 10
/** Snap a coordinate to the editor's live grid step. */
function snapToGridStep(value: number): number {
const step = useEditor.getState().gridSnapStep
if (step <= 0) return value
return Math.round(value / step) * step
}
/** World-space size + centre offset of `box` after the fitting's euler
* rotation — the footprint box that wraps the oriented geometry. */
function rotatedBounds(box: Box3, rotation: Vec3): { size: Vec3; offset: Vec3 } {
const euler = new Euler(rotation[0], rotation[1], rotation[2])
const min = box.min
const max = box.max
const corners: Vec3[] = [
[min.x, min.y, min.z],
[max.x, min.y, min.z],
[min.x, max.y, min.z],
[min.x, min.y, max.z],
[max.x, max.y, min.z],
[max.x, min.y, max.z],
[min.x, max.y, max.z],
[max.x, max.y, max.z],
]
const lo: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]
const hi: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]
const v = new Vector3()
for (const c of corners) {
v.set(c[0], c[1], c[2]).applyEuler(euler)
lo[0] = Math.min(lo[0], v.x)
lo[1] = Math.min(lo[1], v.y)
lo[2] = Math.min(lo[2], v.z)
hi[0] = Math.max(hi[0], v.x)
hi[1] = Math.max(hi[1], v.y)
hi[2] = Math.max(hi[2], v.z)
}
return {
size: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]],
offset: [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2],
}
}
/**
* Ghost-preview duplicate / move tool for DWV pipe fittings (elbow / wye /
* sanitary tee) — the plumbing sibling of the duct-fitting move tool.
*
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
* inserted into the scene until the commit click. A translucent copy of the
* fitting (built from its real geometry, at its own `rotation`, so an elbow
* / riser stays properly aligned) rides the cursor inside a footprint
* bounding box — the same affordance other items get — and Figma-style
* alignment guides snap the box edges to nearby geometry. The commit click
* calls `createNode`; Esc discards.
*
* **Move** (existing fitting): the real node is hidden while the ghost + box
* track the cursor; commit writes the new `position` and reveals it.
*
* Modifiers (mirroring the duct-fitting move):
* - **Alt** detaches: the connected-pipe follow drops so the fitting moves
* on its own, leaving every mated run where it sits.
* - **Ctrl / Cmd** switches to vertical movement (stack / riser editing): XZ
* holds and the cursor's screen-Y drives the riser height.
* - **Shift** bypasses grid snapping / alignment.
*
* Wired via `def.affordanceTools.move`.
*/
export const MovePipeFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
const fitting = node as PipeFittingNode
const originalPosition = (fitting.position ?? [0, 0, 0]) as Vec3
const rotation = (fitting.rotation ?? [0, 0, 0]) as Vec3
const isNew =
typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata) &&
(node.metadata as Record<string, unknown>).isNew === true
const [cursorPos, setCursorPos] = useState<Vec3>(originalPosition)
// Translucent stand-in built from the fitting's real geometry. Rotation is
// a geometry input (it decides the elbow's profile roles), so the ghost
// matches what lands. Rebuilt only if the source changes.
const ghost = useMemo(() => {
const group = buildPipeFittingGeometry(fitting)
group.traverse((obj) => {
const mesh = obj as Mesh
if ((mesh as { isMesh?: boolean }).isMesh) {
mesh.material = new MeshBasicMaterial({
color: GHOST_COLOR,
transparent: true,
opacity: GHOST_OPACITY,
depthTest: false,
})
mesh.renderOrder = 999
}
obj.layers.set(EDITOR_LAYER)
})
return group
}, [fitting])
// Footprint box that wraps the oriented geometry (size + centre offset),
// measured once from the ghost.
const bounds = useMemo(() => {
const box = new Box3().setFromObject(ghost)
if (box.isEmpty()) return { size: [0.3, 0.3, 0.3] as Vec3, offset: [0, 0, 0] as Vec3 }
return rotatedBounds(box, rotation)
}, [ghost, rotation])
useEffect(() => {
return () => {
ghost.traverse((obj) => {
const mesh = obj as Mesh
if ((mesh as { isMesh?: boolean }).isMesh) {
mesh.geometry?.dispose?.()
const mat = mesh.material as Material | Material[]
if (Array.isArray(mat)) for (const m of mat) m.dispose?.()
else mat?.dispose?.()
}
})
}
}, [ghost])
useEffect(() => {
const nodeId = node.id as AnyNodeId
const [hx, , hz] = [bounds.size[0] / 2, 0, bounds.size[2] / 2]
const [ox, , oz] = bounds.offset
useScene.temporal.getState().pause()
let committed = false
let hasMoved = false
const activatedAt = Date.now()
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
useScene.getState().nodes,
nodeId,
useViewer.getState().selection.levelId ?? node.parentId,
)
// Moving an existing fitting: hide its 3D MESH imperatively (NOT the
// store `visible` flag — the 2D floor plan skips `visible:false` nodes,
// so a store hide makes it vanish in 2D / split view). The ghost stands
// in until commit; the real mesh is restored on cancel / unmount.
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
const setMeshHidden = (hidden: boolean) => {
const obj = sceneRegistry.nodes.get(nodeId)
if (obj) obj.visible = !hidden
}
if (existedAtStart) setMeshHidden(true)
// Carry connected pipes as the fitting slides: the part of the move along
// a run's axis stretches it, the part across translates the whole run (and
// propagates to its far joint). Snapshot once at drag start; only existing
// fittings are mated to anything.
const connectivity: RunMoveConnectivity | null = existedAtStart
? startRunMoveConnectivity(node)
: null
let lastPos: Vec3 = originalPosition
// Tracks whether the last frame held Alt: the fitting is detached from its
// connected pipes for the drag, so they stay put (no follow) and the
// commit omits their updates. Mirrors the pipe endpoint's Alt-detach.
let lastDetached = false
// Anchor for the Ctrl-vertical (riser) drag: clientY + base Y captured the
// frame Ctrl is first held, so vertical mouse motion maps to Y. Cleared
// when Ctrl is released. Mirrors the draw tool's Alt-vertical anchor.
let verticalAnchor: { clientY: number; baseY: number } | null = null
const onMove = (event: GridEvent) => {
const bypass = event.nativeEvent?.shiftKey === true
// Alt = detach: drop the connected-pipe follow so the fitting moves on
// its own, leaving every mated run where it sits.
const detached = event.nativeEvent?.altKey === true
// Ctrl/Cmd = vertical: XZ locks to where the fitting sits and the cursor's
// screen-Y drives the riser height (connected pipes still follow).
const vertical = event.nativeEvent?.ctrlKey === true || event.nativeEvent?.metaKey === true
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
const snap = bypass ? (v: number) => v : snapToGridStep
let next: Vec3
if (vertical && typeof clientY === 'number') {
if (!verticalAnchor) verticalAnchor = { clientY, baseY: lastPos[1] }
// Screen +Y points down, so subtract to map "drag up = raise".
const dy = (verticalAnchor.clientY - clientY) / VERTICAL_PIXELS_PER_METER
const y = Math.min(
VERTICAL_Y_MAX_M,
Math.max(VERTICAL_Y_MIN_M, verticalAnchor.baseY + snap(dy)),
)
next = [lastPos[0], y, lastPos[2]]
useAlignmentGuides.getState().clear()
} else {
verticalAnchor = null
let x = snap(event.localPosition[0])
let z = snap(event.localPosition[2])
// Alignment: snap the footprint box edges onto nearby geometry and
// publish guides (Alt / Shift bypass).
if (!bypass) {
const proposed: Aabb2D = {
minX: x + ox - hx,
maxX: x + ox + hx,
minZ: z + oz - hz,
maxZ: z + oz + hz,
}
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
x += dx
z += dz
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
}
next = [x, lastPos[1], z]
}
if (next[0] !== lastPos[0] || next[1] !== lastPos[1] || next[2] !== lastPos[2]) {
triggerSFX('sfx:grid-snap')
}
lastPos = next
lastDetached = detached
hasMoved = true
setCursorPos(next)
// Detached: keep the followers at their origin (drop any live overrides
// from a prior non-detached frame). Otherwise preview the follow.
if (detached) connectivity?.clear()
else connectivity?.preview({ position: next })
}
const commit = (event: GridEvent) => {
if (committed) return
if (Date.now() - activatedAt < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
if (!hasMoved) {
event.nativeEvent?.stopPropagation?.()
return
}
committed = true
useScene.temporal.getState().resume()
let selectId = nodeId
if (isNew && !useScene.getState().nodes[nodeId]) {
const created = PipeFittingNode.parse({
...(node as Record<string, unknown>),
position: lastPos,
metadata: stripPlacementMetadataFlags(node.metadata),
visible: true,
})
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
// Fold connected-pipe / sibling-run follow-updates into the SAME batch
// as the moved fitting so the whole joint is one undo step. Detached
// (Alt on the final frame): the joint is broken, so nothing follows.
const followUpdates = lastDetached
? []
: (connectivity?.commitUpdates({ position: lastPos }) ?? [])
useScene
.getState()
.updateNodes([
{ id: nodeId, data: { position: lastPos } as Partial<AnyNode> },
...followUpdates,
])
useScene.getState().markDirty(nodeId)
}
useScene.temporal.getState().pause()
// Followers are committed to the store — drop their live overrides so
// renderers read the canonical path/position.
connectivity?.clear()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [selectId] })
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
connectivity?.clear()
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
}
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
markToolCancelConsumed()
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', commit)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
connectivity?.clear()
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
}
}, [bounds, isNew, node, originalPosition])
return (
<group>
<primitive object={ghost} position={cursorPos} rotation={rotation} />
<DragBoundingBox
centerY={bounds.offset[1]}
nodeId={node.id}
position={[cursorPos[0] + bounds.offset[0], cursorPos[1], cursorPos[2] + bounds.offset[2]]}
size={bounds.size}
/>
</group>
)
}
export default MovePipeFittingTool
@@ -0,0 +1,106 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeId, PipeFittingNode, PipeSegmentNode } from '@pascal-app/core'
import { pipeFittingParametrics } from './parametrics'
import { getPipeFittingPorts } from './ports'
type Point = [number, number, number]
function pipeElbow() {
return PipeFittingNode.parse({
id: 'pipe-fitting_elbow' as AnyNodeId,
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'DWV bend',
fittingType: 'elbow',
angle: 90,
diameter: 3,
diameter2: 3,
pipeMaterial: 'pvc',
system: 'waste',
position: [0, 0, 0],
rotation: [0, 0, 0],
})
}
function pipe(id: string, path: Point[]) {
return PipeSegmentNode.parse({
id: id as AnyNodeId,
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'DWV pipe',
path,
diameter: 3,
pipeMaterial: 'pvc',
system: 'waste',
})
}
function add(point: readonly number[], dir: readonly number[], length: number): Point {
return [point[0]! + dir[0]! * length, point[1]! + dir[1]! * length, point[2]! + dir[2]! * length]
}
describe('pipeFittingParametrics', () => {
test('deleting an elbow re-extends mated pipe ends back onto the junction', () => {
const fitting = pipeElbow()
const inlet = getPipeFittingPorts(fitting).find((p) => p.id === 'inlet')!
const outlet = getPipeFittingPorts(fitting).find((p) => p.id === 'outlet')!
const inletRun = pipe('pipe-segment_inlet', [
add(inlet.position, inlet.direction, 3),
[...inlet.position] as Point,
])
const outletRun = pipe('pipe-segment_outlet', [
[...outlet.position] as Point,
add(outlet.position, outlet.direction, 3),
])
const nodes: Record<AnyNodeId, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[inletRun.id]: inletRun as AnyNode,
[outletRun.id]: outletRun as AnyNode,
}
const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? []
const inletUpdate = updates.find((u) => u.id === inletRun.id)
const outletUpdate = updates.find((u) => u.id === outletRun.id)
expect((inletUpdate?.data as Partial<PipeSegmentNode>).path?.[1]).toEqual([...fitting.position])
expect((outletUpdate?.data as Partial<PipeSegmentNode>).path?.[0]).toEqual([
...fitting.position,
])
})
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
const fitting = pipeElbow()
const inlet = getPipeFittingPorts(fitting).find((p) => p.id === 'inlet')!
const inletRun = pipe('pipe-segment_inlet', [
add(inlet.position, inlet.direction, 3),
add(inlet.position, [0, 0, 1], 0.04),
])
const nodes: Record<AnyNodeId, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[inletRun.id]: inletRun as AnyNode,
}
const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? []
expect((updates[0]?.data as Partial<PipeSegmentNode>).path?.[1]).toEqual([...fitting.position])
})
test('deleting a branch fitting leaves mated pipe ends untouched', () => {
const wye = PipeFittingNode.parse({ ...pipeElbow(), fittingType: 'wye' })
const inlet = getPipeFittingPorts(wye).find((p) => p.id === 'inlet')!
const inletRun = pipe('pipe-segment_inlet', [
add(inlet.position, inlet.direction, 3),
[...inlet.position] as Point,
])
const nodes: Record<AnyNodeId, AnyNode> = {
[wye.id]: wye as AnyNode,
[inletRun.id]: inletRun as AnyNode,
}
expect(pipeFittingParametrics.onDelete?.(wye, nodes) ?? []).toEqual([])
})
})
+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