nodes: add MEP movement controls and DWV parity (#438)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * feat(duct): ceiling-snap drawing + connected-joint endpoint move Duct draw tool's ceiling mode now hangs each path point just below the ceiling actually covering it (per-room heights tracked), with a translucent surface highlight and a plumb line to the floor so the in-flight point reads clearly from any angle. Dragging a duct corner that sits on a fitting now carries the fitting's other ducts along (port-connectivity second hop), so the joint moves together instead of tearing apart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): detach + vertical modifiers for duct/pipe joint editing Alt detaches a dragged duct/pipe endpoint or fitting from its connected joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical riser movement on the fitting move. Behavioral parity across 2D and 3D. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): full DWV pipe parity for joint editing Bring pipe-segment endpoint drags and pipe-fitting moves to parity with duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow re-aim, and connectivity follow. Generalizes the shared elbow-reaim and auto-fitting helpers to dispatch by run kind so 2D and 3D share one path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): wall-style arrow handles for duct fittings + segments Add violet directional arrow affordances to duct-fitting selection (height, move cross, rotate arc) mirroring the duct-segment rig: portaled into the parent frame to stay out of the selection outline, rendered via the shared HandleArrow, and carrying mated-run connectivity through the single-undo dance. The move cross engages press-drag-release (placementDragMode) the same way the floating drag does, so the markup hit-areas go inert and the fitting move tool commits on pointer-up. Also re-export the HandleArrow primitives from @pascal-app/editor and extend the duct-segment side-move/floorplan affordances. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): click-to-latch cube handles for duct + fitting editing Replace the hover-reveal / multi-handle selection rigs with a single click-to-latch cube that opens a directional cluster, shared between duct segments and fittings via a new selection-handles module (HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube). - Duct segment: per-vertex + run-center cubes reveal axis-locked move chevrons (down arrow always shown), plus a roll arc at the run center. - Duct fitting: center cube reveals six ±XYZ move arrows and three per-axis rotation arcs (oriented in place), replacing the old height/move/rotate trio with axis-cycling. - Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth. - thin chevron profile + press-drag-release commit retained. * fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag Build a fully-determined basis for the duct roll gizmo so the curved arrow always seats at the top-outer 45° corner regardless of run direction, instead of an arbitrary apex from a single setFromUnitVectors. The selection-rig ±Y arrows now own vertical movement, so the redundant Ctrl-modifier riser drag is removed from the fitting move tool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): run-aligned duct handles, swing snapping, elbow flatten Align the duct run-center cube + horizontal arrows to the run axis (matching the per-vertex handles) while keeping whole-run translate. Endpoint side / up-down swing arrows now follow grid snap points and port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow realign + fitting schemas to flatten to a straight 0° coupling. Surface HVAC-specific hints in the select-mode helper panel for duct / fitting. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): per-segment linesets/liquid-lines with joint-follow editing Linesets and liquid lines now commit one independent two-point node per drawn segment instead of folding into a single mitered polyline, so each line selects and deletes on its own. Endpoint caps fill shared-coordinate joints so connected segments still read as continuous pipe. Dragging a shared endpoint carries mated segments along via port connectivity (Alt detaches), so a run still edits as one welded piece. Liquid-line follow mode traces the whole connected lineset run, laying a per-segment parallel line down its full assembled length. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag Lifting/lowering a connected run with the run-center cube now keeps each connected end welded to its stationary partner instead of dragging the whole network. Run-to-run ends get the classic S/Z offset (two elbows + plumb riser, partner trimmed back one leg); elbow-connected ends form a clean L — the existing elbow stays put and re-aims its collar vertical, with one new top elbow + riser reconnecting to the lifted endpoint. The offset is ghosted live and minted as a single undo step on release. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add roof accessory placement guides Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters. * Improve duct and placement routing * Fix duct vertical movement routing * Fix duct vertical offsets and roof accessory movement * Add DWV movement parity and line endpoint controls * Fix MEP handle review issues * Fix chimney placement and duct offset cleanup * Use snapped targets for roof accessory commits * fix(nodes): repair MEP movement review issues - auto-fitting: tee branch now follows the drawn lateral angle; update the stale square-tee test + doc comment that contradicted the rewrite - duct-segment: re-enable the vertical auto-offset rewind (the disabled stub left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers and stacked duplicates); remove the dead stub - duct-segment: strip the stale auto-offset tag on manual corner/roll commits so the horizontal-move path no longer trusts an out-of-date base - chimney: resume history before mutating segment children arrays so a cross-segment move reparents in one tracked transaction (undo stays consistent) - dormer: align schema test with the new windowSill=false default * fix: address mep movement review issues * fix: address follow-up mep review comments * fix: address additional mep review comments --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: pascal-open-bot <open@pascal.app>
This commit is contained in:
co-authored by
Claude Opus 4.6
pascal-open-bot
parent
ae90da6554
commit
a71de82ccb
@@ -78,6 +78,7 @@ export const pipeFittingDefinition: NodeDefinition<typeof PipeFittingNode> = {
|
||||
// editor's SelectionAffordanceManager rather than `def.system`.
|
||||
affordanceTools: {
|
||||
selection: () => import('./selection'),
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
|
||||
tool: () => import('./tool'),
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
PipeFittingNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
DragBoundingBox,
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
stripPlacementMetadataFlags,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
|
||||
import {
|
||||
type Aabb2D,
|
||||
collectGhostAlignmentCandidates,
|
||||
resolveGhostAlignment,
|
||||
} from '../shared/ghost-alignment'
|
||||
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
||||
import { buildPipeFittingGeometry } from './geometry'
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
|
||||
const GHOST_COLOR = '#818cf8'
|
||||
const GHOST_OPACITY = 0.5
|
||||
|
||||
/** Screen pixels → meters for the Ctrl-vertical (riser) drag — matches the
|
||||
* pipe draw tool's Alt-vertical feel. 100 px ≈ 1 m. */
|
||||
const VERTICAL_PIXELS_PER_METER = 100
|
||||
const VERTICAL_Y_MIN_M = -3
|
||||
const VERTICAL_Y_MAX_M = 10
|
||||
|
||||
/** Snap a coordinate to the editor's live grid step. */
|
||||
function snapToGridStep(value: number): number {
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
/** World-space size + centre offset of `box` after the fitting's euler
|
||||
* rotation — the footprint box that wraps the oriented geometry. */
|
||||
function rotatedBounds(box: Box3, rotation: Vec3): { size: Vec3; offset: Vec3 } {
|
||||
const euler = new Euler(rotation[0], rotation[1], rotation[2])
|
||||
const min = box.min
|
||||
const max = box.max
|
||||
const corners: Vec3[] = [
|
||||
[min.x, min.y, min.z],
|
||||
[max.x, min.y, min.z],
|
||||
[min.x, max.y, min.z],
|
||||
[min.x, min.y, max.z],
|
||||
[max.x, max.y, min.z],
|
||||
[max.x, min.y, max.z],
|
||||
[min.x, max.y, max.z],
|
||||
[max.x, max.y, max.z],
|
||||
]
|
||||
const lo: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]
|
||||
const hi: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]
|
||||
const v = new Vector3()
|
||||
for (const c of corners) {
|
||||
v.set(c[0], c[1], c[2]).applyEuler(euler)
|
||||
lo[0] = Math.min(lo[0], v.x)
|
||||
lo[1] = Math.min(lo[1], v.y)
|
||||
lo[2] = Math.min(lo[2], v.z)
|
||||
hi[0] = Math.max(hi[0], v.x)
|
||||
hi[1] = Math.max(hi[1], v.y)
|
||||
hi[2] = Math.max(hi[2], v.z)
|
||||
}
|
||||
return {
|
||||
size: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]],
|
||||
offset: [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghost-preview duplicate / move tool for DWV pipe fittings (elbow / wye /
|
||||
* sanitary tee) — the plumbing sibling of the duct-fitting move tool.
|
||||
*
|
||||
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
|
||||
* inserted into the scene until the commit click. A translucent copy of the
|
||||
* fitting (built from its real geometry, at its own `rotation`, so an elbow
|
||||
* / riser stays properly aligned) rides the cursor inside a footprint
|
||||
* bounding box — the same affordance other items get — and Figma-style
|
||||
* alignment guides snap the box edges to nearby geometry. The commit click
|
||||
* calls `createNode`; Esc discards.
|
||||
*
|
||||
* **Move** (existing fitting): the real node is hidden while the ghost + box
|
||||
* track the cursor; commit writes the new `position` and reveals it.
|
||||
*
|
||||
* Modifiers (mirroring the duct-fitting move):
|
||||
* - **Alt** detaches: the connected-pipe follow drops so the fitting moves
|
||||
* on its own, leaving every mated run where it sits.
|
||||
* - **Ctrl / Cmd** switches to vertical movement (stack / riser editing): XZ
|
||||
* holds and the cursor's screen-Y drives the riser height.
|
||||
* - **Shift** bypasses grid snapping / alignment.
|
||||
*
|
||||
* Wired via `def.affordanceTools.move`.
|
||||
*/
|
||||
export const MovePipeFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
const fitting = node as PipeFittingNode
|
||||
const originalPosition = (fitting.position ?? [0, 0, 0]) as Vec3
|
||||
const rotation = (fitting.rotation ?? [0, 0, 0]) as Vec3
|
||||
const isNew =
|
||||
typeof node.metadata === 'object' &&
|
||||
node.metadata !== null &&
|
||||
!Array.isArray(node.metadata) &&
|
||||
(node.metadata as Record<string, unknown>).isNew === true
|
||||
|
||||
const [cursorPos, setCursorPos] = useState<Vec3>(originalPosition)
|
||||
|
||||
// Translucent stand-in built from the fitting's real geometry. Rotation is
|
||||
// a geometry input (it decides the elbow's profile roles), so the ghost
|
||||
// matches what lands. Rebuilt only if the source changes.
|
||||
const ghost = useMemo(() => {
|
||||
const group = buildPipeFittingGeometry(fitting)
|
||||
group.traverse((obj) => {
|
||||
const mesh = obj as Mesh
|
||||
if ((mesh as { isMesh?: boolean }).isMesh) {
|
||||
mesh.material = new MeshBasicMaterial({
|
||||
color: GHOST_COLOR,
|
||||
transparent: true,
|
||||
opacity: GHOST_OPACITY,
|
||||
depthTest: false,
|
||||
})
|
||||
mesh.renderOrder = 999
|
||||
}
|
||||
obj.layers.set(EDITOR_LAYER)
|
||||
})
|
||||
return group
|
||||
}, [fitting])
|
||||
|
||||
// Footprint box that wraps the oriented geometry (size + centre offset),
|
||||
// measured once from the ghost.
|
||||
const bounds = useMemo(() => {
|
||||
const box = new Box3().setFromObject(ghost)
|
||||
if (box.isEmpty()) return { size: [0.3, 0.3, 0.3] as Vec3, offset: [0, 0, 0] as Vec3 }
|
||||
return rotatedBounds(box, rotation)
|
||||
}, [ghost, rotation])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
ghost.traverse((obj) => {
|
||||
const mesh = obj as Mesh
|
||||
if ((mesh as { isMesh?: boolean }).isMesh) {
|
||||
mesh.geometry?.dispose?.()
|
||||
const mat = mesh.material as Material | Material[]
|
||||
if (Array.isArray(mat)) for (const m of mat) m.dispose?.()
|
||||
else mat?.dispose?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [ghost])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = node.id as AnyNodeId
|
||||
const [hx, , hz] = [bounds.size[0] / 2, 0, bounds.size[2] / 2]
|
||||
const [ox, , oz] = bounds.offset
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let committed = false
|
||||
let hasMoved = false
|
||||
const activatedAt = Date.now()
|
||||
|
||||
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
|
||||
useScene.getState().nodes,
|
||||
nodeId,
|
||||
useViewer.getState().selection.levelId ?? node.parentId,
|
||||
)
|
||||
|
||||
// Moving an existing fitting: hide its 3D MESH imperatively (NOT the
|
||||
// store `visible` flag — the 2D floor plan skips `visible:false` nodes,
|
||||
// so a store hide makes it vanish in 2D / split view). The ghost stands
|
||||
// in until commit; the real mesh is restored on cancel / unmount.
|
||||
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
|
||||
const setMeshHidden = (hidden: boolean) => {
|
||||
const obj = sceneRegistry.nodes.get(nodeId)
|
||||
if (obj) obj.visible = !hidden
|
||||
}
|
||||
if (existedAtStart) setMeshHidden(true)
|
||||
|
||||
// Carry connected pipes as the fitting slides: the part of the move along
|
||||
// a run's axis stretches it, the part across translates the whole run (and
|
||||
// propagates to its far joint). Snapshot once at drag start; only existing
|
||||
// fittings are mated to anything.
|
||||
const connectivity: RunMoveConnectivity | null = existedAtStart
|
||||
? startRunMoveConnectivity(node)
|
||||
: null
|
||||
|
||||
let lastPos: Vec3 = originalPosition
|
||||
// Tracks whether the last frame held Alt: the fitting is detached from its
|
||||
// connected pipes for the drag, so they stay put (no follow) and the
|
||||
// commit omits their updates. Mirrors the pipe endpoint's Alt-detach.
|
||||
let lastDetached = false
|
||||
// Anchor for the Ctrl-vertical (riser) drag: clientY + base Y captured the
|
||||
// frame Ctrl is first held, so vertical mouse motion maps to Y. Cleared
|
||||
// when Ctrl is released. Mirrors the draw tool's Alt-vertical anchor.
|
||||
let verticalAnchor: { clientY: number; baseY: number } | null = null
|
||||
|
||||
const onMove = (event: GridEvent) => {
|
||||
const bypass = event.nativeEvent?.shiftKey === true
|
||||
// Alt = detach: drop the connected-pipe follow so the fitting moves on
|
||||
// its own, leaving every mated run where it sits.
|
||||
const detached = event.nativeEvent?.altKey === true
|
||||
// Ctrl/Cmd = vertical: XZ locks to where the fitting sits and the cursor's
|
||||
// screen-Y drives the riser height (connected pipes still follow).
|
||||
const vertical = event.nativeEvent?.ctrlKey === true || event.nativeEvent?.metaKey === true
|
||||
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
|
||||
const snap = bypass ? (v: number) => v : snapToGridStep
|
||||
|
||||
let next: Vec3
|
||||
if (vertical && typeof clientY === 'number') {
|
||||
if (!verticalAnchor) verticalAnchor = { clientY, baseY: lastPos[1] }
|
||||
// Screen +Y points down, so subtract to map "drag up = raise".
|
||||
const dy = (verticalAnchor.clientY - clientY) / VERTICAL_PIXELS_PER_METER
|
||||
const y = Math.min(
|
||||
VERTICAL_Y_MAX_M,
|
||||
Math.max(VERTICAL_Y_MIN_M, verticalAnchor.baseY + snap(dy)),
|
||||
)
|
||||
next = [lastPos[0], y, lastPos[2]]
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
verticalAnchor = null
|
||||
let x = snap(event.localPosition[0])
|
||||
let z = snap(event.localPosition[2])
|
||||
|
||||
// Alignment: snap the footprint box edges onto nearby geometry and
|
||||
// publish guides (Alt / Shift bypass).
|
||||
if (!bypass) {
|
||||
const proposed: Aabb2D = {
|
||||
minX: x + ox - hx,
|
||||
maxX: x + ox + hx,
|
||||
minZ: z + oz - hz,
|
||||
maxZ: z + oz + hz,
|
||||
}
|
||||
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
||||
x += dx
|
||||
z += dz
|
||||
useAlignmentGuides.getState().set(guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
next = [x, lastPos[1], z]
|
||||
}
|
||||
|
||||
if (next[0] !== lastPos[0] || next[1] !== lastPos[1] || next[2] !== lastPos[2]) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
lastPos = next
|
||||
lastDetached = detached
|
||||
hasMoved = true
|
||||
setCursorPos(next)
|
||||
// Detached: keep the followers at their origin (drop any live overrides
|
||||
// from a prior non-detached frame). Otherwise preview the follow.
|
||||
if (detached) connectivity?.clear()
|
||||
else connectivity?.preview({ position: next })
|
||||
}
|
||||
|
||||
const commit = (event: GridEvent) => {
|
||||
if (committed) return
|
||||
if (Date.now() - activatedAt < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
if (!hasMoved) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
let selectId = nodeId
|
||||
if (isNew && !useScene.getState().nodes[nodeId]) {
|
||||
const created = PipeFittingNode.parse({
|
||||
...(node as Record<string, unknown>),
|
||||
position: lastPos,
|
||||
metadata: stripPlacementMetadataFlags(node.metadata),
|
||||
visible: true,
|
||||
})
|
||||
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
|
||||
selectId = created.id as AnyNodeId
|
||||
} else {
|
||||
// Fold connected-pipe / sibling-run follow-updates into the SAME batch
|
||||
// as the moved fitting so the whole joint is one undo step. Detached
|
||||
// (Alt on the final frame): the joint is broken, so nothing follows.
|
||||
const followUpdates = lastDetached
|
||||
? []
|
||||
: (connectivity?.commitUpdates({ position: lastPos }) ?? [])
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([
|
||||
{ id: nodeId, data: { position: lastPos } as Partial<AnyNode> },
|
||||
...followUpdates,
|
||||
])
|
||||
useScene.getState().markDirty(nodeId)
|
||||
}
|
||||
useScene.temporal.getState().pause()
|
||||
// Followers are committed to the store — drop their live overrides so
|
||||
// renderers read the canonical path/position.
|
||||
connectivity?.clear()
|
||||
setMeshHidden(false)
|
||||
|
||||
useAlignmentGuides.getState().clear()
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [selectId] })
|
||||
useEditor.getState().setMovingNodeOrigin('3d')
|
||||
useEditor.getState().setMovingNode(null)
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
}
|
||||
useAlignmentGuides.getState().clear()
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
useEditor.getState().setMovingNodeOrigin('3d')
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onMove)
|
||||
emitter.on('grid:click', commit)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', commit)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
connectivity?.clear()
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (existedAtStart) setMeshHidden(false)
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [bounds, isNew, node, originalPosition])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<primitive object={ghost} position={cursorPos} rotation={rotation} />
|
||||
<DragBoundingBox
|
||||
centerY={bounds.offset[1]}
|
||||
nodeId={node.id}
|
||||
position={[cursorPos[0] + bounds.offset[0], cursorPos[1], cursorPos[2] + bounds.offset[2]]}
|
||||
size={bounds.size}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default MovePipeFittingTool
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, type AnyNodeId, PipeFittingNode, PipeSegmentNode } from '@pascal-app/core'
|
||||
import { pipeFittingParametrics } from './parametrics'
|
||||
import { getPipeFittingPorts } from './ports'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
function pipeElbow() {
|
||||
return PipeFittingNode.parse({
|
||||
id: 'pipe-fitting_elbow' as AnyNodeId,
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'DWV bend',
|
||||
fittingType: 'elbow',
|
||||
angle: 90,
|
||||
diameter: 3,
|
||||
diameter2: 3,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
})
|
||||
}
|
||||
|
||||
function pipe(id: string, path: Point[]) {
|
||||
return PipeSegmentNode.parse({
|
||||
id: id as AnyNodeId,
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'DWV pipe',
|
||||
path,
|
||||
diameter: 3,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
})
|
||||
}
|
||||
|
||||
function add(point: readonly number[], dir: readonly number[], length: number): Point {
|
||||
return [point[0]! + dir[0]! * length, point[1]! + dir[1]! * length, point[2]! + dir[2]! * length]
|
||||
}
|
||||
|
||||
describe('pipeFittingParametrics', () => {
|
||||
test('deleting an elbow re-extends mated pipe ends back onto the junction', () => {
|
||||
const fitting = pipeElbow()
|
||||
const inlet = getPipeFittingPorts(fitting).find((p) => p.id === 'inlet')!
|
||||
const outlet = getPipeFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const inletRun = pipe('pipe-segment_inlet', [
|
||||
add(inlet.position, inlet.direction, 3),
|
||||
[...inlet.position] as Point,
|
||||
])
|
||||
const outletRun = pipe('pipe-segment_outlet', [
|
||||
[...outlet.position] as Point,
|
||||
add(outlet.position, outlet.direction, 3),
|
||||
])
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
[outletRun.id]: outletRun as AnyNode,
|
||||
}
|
||||
|
||||
const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
const inletUpdate = updates.find((u) => u.id === inletRun.id)
|
||||
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
||||
|
||||
expect((inletUpdate?.data as Partial<PipeSegmentNode>).path?.[1]).toEqual([...fitting.position])
|
||||
expect((outletUpdate?.data as Partial<PipeSegmentNode>).path?.[0]).toEqual([
|
||||
...fitting.position,
|
||||
])
|
||||
})
|
||||
|
||||
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
|
||||
const fitting = pipeElbow()
|
||||
const inlet = getPipeFittingPorts(fitting).find((p) => p.id === 'inlet')!
|
||||
const inletRun = pipe('pipe-segment_inlet', [
|
||||
add(inlet.position, inlet.direction, 3),
|
||||
add(inlet.position, [0, 0, 1], 0.04),
|
||||
])
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
}
|
||||
|
||||
const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
|
||||
expect((updates[0]?.data as Partial<PipeSegmentNode>).path?.[1]).toEqual([...fitting.position])
|
||||
})
|
||||
|
||||
test('deleting a branch fitting leaves mated pipe ends untouched', () => {
|
||||
const wye = PipeFittingNode.parse({ ...pipeElbow(), fittingType: 'wye' })
|
||||
const inlet = getPipeFittingPorts(wye).find((p) => p.id === 'inlet')!
|
||||
const inletRun = pipe('pipe-segment_inlet', [
|
||||
add(inlet.position, inlet.direction, 3),
|
||||
[...inlet.position] as Point,
|
||||
])
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[wye.id]: wye as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
}
|
||||
|
||||
expect(pipeFittingParametrics.onDelete?.(wye, nodes) ?? []).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,62 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { AnyNode, AnyNodeId, ParametricDescriptor, PipeSegmentNode } from '@pascal-app/core'
|
||||
import { getPipeFittingPorts } from './ports'
|
||||
import type { PipeFittingNode } from './schema'
|
||||
|
||||
/** A pipe endpoint sitting this close to a fitting hub counts as mated. */
|
||||
const MATE_TOL_M = 0.05
|
||||
|
||||
type Point = [number, number, number]
|
||||
type PipeMate = { pipe: PipeSegmentNode; endIndex: number }
|
||||
|
||||
function matedPipes(
|
||||
fitting: PipeFittingNode,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): Map<string, PipeMate> {
|
||||
const mates = new Map<string, PipeMate>()
|
||||
const ports = getPipeFittingPorts(fitting)
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'pipe-segment') continue
|
||||
const pipe = node as PipeSegmentNode
|
||||
for (const endIndex of [0, pipe.path.length - 1]) {
|
||||
const p = pipe.path[endIndex]
|
||||
if (!p) continue
|
||||
for (const port of ports) {
|
||||
if (mates.has(port.id)) continue
|
||||
const dx = p[0] - port.position[0]
|
||||
const dy = p[1] - port.position[1]
|
||||
const dz = p[2] - port.position[2]
|
||||
if (dx * dx + dy * dy + dz * dz <= MATE_TOL_M * MATE_TOL_M) {
|
||||
mates.set(port.id, { pipe, endIndex })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return mates
|
||||
}
|
||||
|
||||
export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
|
||||
// Deleting an auto-inserted DWV bend restores the corner it replaced.
|
||||
// The connected pipe endpoints were pulled back onto the bend collars;
|
||||
// send those endpoints back to the junction so the L-shape regains its
|
||||
// original length.
|
||||
onDelete: (fitting, nodes) => {
|
||||
if (fitting.fittingType !== 'elbow') return []
|
||||
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
||||
for (const mate of matedPipes(fitting, nodes).values()) {
|
||||
const end = mate.pipe.path[mate.endIndex]
|
||||
if (!end) continue
|
||||
const target = fitting.position
|
||||
const dx = end[0] - target[0]
|
||||
const dy = end[1] - target[1]
|
||||
const dz = end[2] - target[2]
|
||||
if (dx * dx + dy * dy + dz * dz < 1e-12) continue
|
||||
const path = mate.pipe.path.map((p) => [...p] as Point)
|
||||
path[mate.endIndex] = [...target]
|
||||
updates.push({ id: mate.pipe.id, data: { path } as Partial<PipeSegmentNode> })
|
||||
}
|
||||
return updates
|
||||
},
|
||||
|
||||
groups: [
|
||||
{
|
||||
label: 'Fitting',
|
||||
@@ -16,7 +71,7 @@ export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
|
||||
key: 'angle',
|
||||
kind: 'number',
|
||||
unit: '°',
|
||||
min: 15,
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 7.5,
|
||||
visibleIf: (n) => n.fittingType === 'elbow',
|
||||
|
||||
@@ -1,27 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
analyzePortConnectivity,
|
||||
type Cursor,
|
||||
type PipeFittingNode,
|
||||
type PortConnectivity,
|
||||
pauseSceneHistory,
|
||||
resolveConnectivityUpdates,
|
||||
resumeSceneHistory,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ARROW_COLOR,
|
||||
EDITOR_LAYER,
|
||||
swallowNextClick,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { cycleRotationAxis } from '../shared/fitting-rotation'
|
||||
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
LineSegments,
|
||||
type Object3D,
|
||||
OrthographicCamera,
|
||||
Plane,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
SphereGeometry,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
AXIS_VECTORS,
|
||||
cycleRotationAxis,
|
||||
ROTATE_STEP_RAD,
|
||||
type RotationAxis,
|
||||
} from '../shared/fitting-rotation'
|
||||
import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles'
|
||||
import { pipeFittingLegLength } from './ports'
|
||||
|
||||
type Point = [number, number, number]
|
||||
type FittingTransform = { position?: Point; rotation?: Point }
|
||||
type PipeDimension = 'diameter' | 'diameter2'
|
||||
|
||||
const ARROW_GAP = 0.34
|
||||
const RESIZE_HANDLE_GAP = 0.3
|
||||
const RESIZE_STEP_IN = 0.25
|
||||
const RESIZE_GUIDE_DASH = 0.07
|
||||
const RESIZE_GUIDE_GAP = 0.045
|
||||
const RESIZE_SPHERE_RADIUS = 0.065
|
||||
const RESIZE_HIT_RADIUS = 0.13
|
||||
const INCHES_TO_METERS = 0.0254
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function fittingExtentM(node: PipeFittingNode): number {
|
||||
return Math.max(pipeFittingLegLength(node.diameter), pipeFittingLegLength(node.diameter2))
|
||||
}
|
||||
|
||||
function fittingParameterPatch(node: PipeFittingNode): Partial<PipeFittingNode> {
|
||||
return {
|
||||
fittingType: node.fittingType,
|
||||
angle: node.angle,
|
||||
diameter: node.diameter,
|
||||
diameter2: node.diameter2,
|
||||
pipeMaterial: node.pipeMaterial,
|
||||
system: node.system,
|
||||
}
|
||||
}
|
||||
|
||||
function preserveFittingParameters(
|
||||
node: PipeFittingNode,
|
||||
data: Partial<PipeFittingNode>,
|
||||
): Partial<AnyNode> {
|
||||
return { ...fittingParameterPatch(node), ...data } as Partial<AnyNode>
|
||||
}
|
||||
|
||||
function dimensionPatch(
|
||||
fitting: PipeFittingNode,
|
||||
dimension: PipeDimension,
|
||||
value: number,
|
||||
): Partial<PipeFittingNode> {
|
||||
if (dimension === 'diameter' && fitting.fittingType === 'elbow') {
|
||||
return { diameter: value, diameter2: value }
|
||||
}
|
||||
return { [dimension]: value } as Partial<PipeFittingNode>
|
||||
}
|
||||
|
||||
function closestAxisParameterToRay(
|
||||
axisOrigin: Vector3,
|
||||
axisDirection: Vector3,
|
||||
ray: Raycaster['ray'],
|
||||
) {
|
||||
const originToRay = axisOrigin.clone().sub(ray.origin)
|
||||
const b = axisDirection.dot(ray.direction)
|
||||
const d = axisDirection.dot(originToRay)
|
||||
const e = ray.direction.dot(originToRay)
|
||||
const denominator = 1 - b * b
|
||||
if (Math.abs(denominator) < 1e-6) return -d
|
||||
const axisParameter = (b * e - d) / denominator
|
||||
const rayParameter = e + b * axisParameter
|
||||
return rayParameter < 0 ? -d : axisParameter
|
||||
}
|
||||
|
||||
function DashedResizeGuide({ from, to }: { from: Point; to: Point }) {
|
||||
const line = useMemo(() => {
|
||||
const a = new Vector3(from[0], from[1], from[2])
|
||||
const b = new Vector3(to[0], to[1], to[2])
|
||||
const span = b.clone().sub(a)
|
||||
const length = span.length()
|
||||
const points: number[] = []
|
||||
if (length > 1e-4) {
|
||||
const dir = span.clone().normalize()
|
||||
let t = 0
|
||||
while (t < length) {
|
||||
const start = a.clone().addScaledVector(dir, t)
|
||||
const end = a.clone().addScaledVector(dir, Math.min(t + RESIZE_GUIDE_DASH, length))
|
||||
points.push(start.x, start.y, start.z, end.x, end.y, end.z)
|
||||
t += RESIZE_GUIDE_DASH + RESIZE_GUIDE_GAP
|
||||
}
|
||||
}
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(points), 3))
|
||||
const material = new LineBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
depthWrite: false,
|
||||
})
|
||||
const next = new LineSegments(geometry, material)
|
||||
next.frustumCulled = false
|
||||
next.layers.set(EDITOR_LAYER)
|
||||
next.renderOrder = 1002
|
||||
next.raycast = () => {}
|
||||
return next
|
||||
}, [from, to])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
line.geometry.dispose()
|
||||
;(line.material as LineBasicNodeMaterial).dispose()
|
||||
},
|
||||
[line],
|
||||
)
|
||||
|
||||
return <primitive object={line} />
|
||||
}
|
||||
|
||||
function ResizeSphereHandle({
|
||||
cursor,
|
||||
onPointerDown,
|
||||
position,
|
||||
}: {
|
||||
cursor: Cursor
|
||||
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||
position: Point
|
||||
}) {
|
||||
const { camera } = useThree()
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const sphereGeometry = useMemo(() => new SphereGeometry(RESIZE_SPHERE_RADIUS, 18, 12), [])
|
||||
const hitGeometry = useMemo(() => new SphereGeometry(RESIZE_HIT_RADIUS, 12, 8), [])
|
||||
const sphereMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.92,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const hitMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
sphereMaterial.opacity = hovered ? 1 : 0.92
|
||||
}, [sphereMaterial, hovered])
|
||||
useEffect(
|
||||
() => () => {
|
||||
hitGeometry.dispose()
|
||||
sphereGeometry.dispose()
|
||||
sphereMaterial.dispose()
|
||||
hitMaterial.dispose()
|
||||
},
|
||||
[hitGeometry, hitMaterial, sphereGeometry, sphereMaterial],
|
||||
)
|
||||
|
||||
const consumePress = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.stopPropagation()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
swallowNextClick()
|
||||
onPointerDown(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={position} scale={zoom}>
|
||||
<mesh
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={consumePress}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
setHovered(true)
|
||||
document.body.style.cursor = cursor
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
event.stopPropagation()
|
||||
setHovered(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}}
|
||||
/>
|
||||
<mesh geometry={sphereGeometry} material={sphereMaterial} renderOrder={1004} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-time rotation support for placed pipe fittings — mirrors
|
||||
* the duct-fitting affordance, mounted by the editor's
|
||||
* SelectionAffordanceManager (`def.affordanceTools.selection`). R/T
|
||||
* rotation lives in `def.keyboardActions`; this contributes the piece
|
||||
* that hook can't: **Alt cycles the active rotation axis** while a
|
||||
* single fitting is selected. The axis lives on `useEditor.rotationAxis`,
|
||||
* which the floating action menu reads to show the axis pill — so this
|
||||
* component renders nothing.
|
||||
*/
|
||||
const PipeFittingSelectionAffordance = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const hasSelectedFitting = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return false
|
||||
return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'pipe-fitting'
|
||||
const fitting = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
||||
return node?.type === 'pipe-fitting' ? (node as PipeFittingNode) : null
|
||||
})
|
||||
|
||||
const hasSelectedFitting = !!fitting
|
||||
useEffect(() => {
|
||||
if (!hasSelectedFitting) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -31,13 +263,527 @@ const PipeFittingSelectionAffordance = () => {
|
||||
e.preventDefault()
|
||||
cycleRotationAxis()
|
||||
}
|
||||
// Bubble phase — when the placement tool is active its capture-phase
|
||||
// handler stops propagation, so the two never double-cycle.
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [hasSelectedFitting])
|
||||
|
||||
return null
|
||||
const fittingId = fitting?.id ?? null
|
||||
const [target, setTarget] = useState<Object3D | null>(null)
|
||||
useEffect(() => {
|
||||
if (!fittingId) {
|
||||
setTarget(null)
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(fittingId as AnyNodeId) ?? null
|
||||
setTarget((cur) => (cur === next ? cur : next))
|
||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
resolve()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [fittingId])
|
||||
|
||||
if (!fitting || !target) return null
|
||||
const mount = target.parent ?? target
|
||||
return createPortal(<FittingHandles fitting={fitting} />, mount, undefined)
|
||||
}
|
||||
|
||||
const FittingHandles = ({ fitting }: { fitting: PipeFittingNode }) => {
|
||||
const { camera, gl } = useThree()
|
||||
const [frame, setFrame] = useState<Group | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [sideSign, setSideSign] = useState(1)
|
||||
|
||||
const makeRay = (clientX: number, clientY: number) => {
|
||||
const rect = gl.domElement.getBoundingClientRect()
|
||||
const ndc = new Vector2(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
const raycaster = new Raycaster()
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
return raycaster.ray
|
||||
}
|
||||
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
|
||||
const hit = new Vector3()
|
||||
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
|
||||
}
|
||||
const sampleAxisParameter = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
axisOrigin: Vector3,
|
||||
axisDirection: Vector3,
|
||||
): number => closestAxisParameterToRay(axisOrigin, axisDirection, makeRay(clientX, clientY))
|
||||
const intersectVerticalY = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
anchorWorld: Vector3,
|
||||
): number | null => {
|
||||
if (!frame) return null
|
||||
const forward = camera.getWorldDirection(new Vector3())
|
||||
forward.y = 0
|
||||
if (forward.lengthSq() < 1e-6) forward.set(0, 0, 1)
|
||||
forward.normalize()
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(forward, anchorWorld)
|
||||
const hit = intersect(clientX, clientY, plane)
|
||||
return hit ? frame.worldToLocal(hit.clone()).y : null
|
||||
}
|
||||
|
||||
const toWorld = (p: Point): Vector3 =>
|
||||
frame ? frame.localToWorld(new Vector3(p[0], p[1], p[2])) : new Vector3(p[0], p[1], p[2])
|
||||
const axisToWorld = (origin: Point, axis: Vector3): Vector3 => {
|
||||
const originWorld = toWorld(origin)
|
||||
const tipWorld = frame
|
||||
? frame.localToWorld(new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z))
|
||||
: new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z)
|
||||
return tipWorld.sub(originWorld).normalize()
|
||||
}
|
||||
|
||||
const sampleAxis = (
|
||||
axis: RotationAxis,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
anchorWorld: Vector3,
|
||||
): number | null => {
|
||||
if (axis === 'y') return intersectVerticalY(clientX, clientY, anchorWorld)
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, anchorWorld)
|
||||
const hit = intersect(clientX, clientY, plane)
|
||||
if (!hit || !frame) return null
|
||||
const local = frame.worldToLocal(hit.clone())
|
||||
return axis === 'x' ? local.x : local.z
|
||||
}
|
||||
|
||||
const connectivityUpdates = (
|
||||
connectivity: PortConnectivity | null,
|
||||
transform: FittingTransform,
|
||||
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
||||
if (!connectivity) return []
|
||||
const preview = { ...(fitting as Record<string, unknown>), ...transform } as AnyNode
|
||||
const nodes = useScene.getState().nodes
|
||||
return resolveConnectivityUpdates(connectivity, preview)
|
||||
.filter((u) => nodes[u.id])
|
||||
.map((u) => {
|
||||
const node = nodes[u.id]
|
||||
if (node?.type !== 'pipe-fitting') return u
|
||||
return {
|
||||
id: u.id,
|
||||
data: preserveFittingParameters(
|
||||
node as PipeFittingNode,
|
||||
u.data as Partial<PipeFittingNode>,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const beginDrag =
|
||||
(
|
||||
cursor: Cursor,
|
||||
makeCompute: (
|
||||
e: ThreeEvent<PointerEvent>,
|
||||
) => (event: PointerEvent) => FittingTransform | null,
|
||||
) =>
|
||||
(e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const initialPosition = [...fitting.position] as Point
|
||||
const initialRotation = [...fitting.rotation] as Point
|
||||
const connectivity = analyzePortConnectivity(fitting as AnyNode, useScene.getState().nodes)
|
||||
const compute = makeCompute(e)
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
setDragging(true)
|
||||
document.body.style.cursor = cursor
|
||||
let current: FittingTransform | null = null
|
||||
|
||||
const buildBatch = (t: FittingTransform): { id: AnyNodeId; data: Partial<AnyNode> }[] => [
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, t as Partial<PipeFittingNode>),
|
||||
},
|
||||
...connectivityUpdates(connectivity, t),
|
||||
]
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const next = compute(event)
|
||||
if (!next) return
|
||||
current = next
|
||||
useScene.getState().updateNodes(buildBatch(next))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
setDragging(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
swallowNextClick()
|
||||
cleanup()
|
||||
const reverts: { id: AnyNodeId; data: Partial<AnyNode> }[] = (
|
||||
connectivity?.connections ?? []
|
||||
).map((conn) => {
|
||||
if (conn.kind !== 'rigid-node') {
|
||||
return { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }
|
||||
}
|
||||
const node = useScene.getState().nodes[conn.nodeId]
|
||||
return {
|
||||
id: conn.nodeId,
|
||||
data:
|
||||
node?.type === 'pipe-fitting'
|
||||
? preserveFittingParameters(node as PipeFittingNode, {
|
||||
position: conn.startPosition as Point,
|
||||
})
|
||||
: ({ position: conn.startPosition } as Partial<AnyNode>),
|
||||
}
|
||||
})
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, {
|
||||
position: initialPosition,
|
||||
rotation: initialRotation,
|
||||
}),
|
||||
},
|
||||
...reverts.filter((u) => useScene.getState().nodes[u.id]),
|
||||
])
|
||||
resumeSceneHistory(useScene)
|
||||
if (current) useScene.getState().updateNodes(buildBatch(current))
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
const moveCompute =
|
||||
(axis: RotationAxis) =>
|
||||
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
|
||||
const anchorWorld = toWorld(fitting.position as Point)
|
||||
const start = sampleAxis(axis, e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld)
|
||||
const base = [...fitting.position] as Point
|
||||
const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
||||
let lastDelta = Number.NaN
|
||||
return (event: PointerEvent): FittingTransform | null => {
|
||||
if (start === null) return null
|
||||
const s = sampleAxis(axis, event.clientX, event.clientY, anchorWorld)
|
||||
if (s === null) return null
|
||||
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
||||
const delta = snap(s - start, step)
|
||||
if (delta === lastDelta) return null
|
||||
lastDelta = delta
|
||||
if (step > 0) triggerSFX('sfx:grid-snap')
|
||||
const next = [...base] as Point
|
||||
next[axisIndex] = base[axisIndex] + delta
|
||||
return { position: next }
|
||||
}
|
||||
}
|
||||
|
||||
const rotateCompute =
|
||||
(axis: RotationAxis) =>
|
||||
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
|
||||
const normal = AXIS_VECTORS[axis].clone()
|
||||
const center = toWorld(fitting.position as Point)
|
||||
const ref = axis === 'y' ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0)
|
||||
const u = ref
|
||||
.clone()
|
||||
.sub(normal.clone().multiplyScalar(ref.dot(normal)))
|
||||
.normalize()
|
||||
const v = new Vector3().crossVectors(normal, u)
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center)
|
||||
const bearing = (clientX: number, clientY: number): number | null => {
|
||||
const hit = intersect(clientX, clientY, plane)
|
||||
if (!hit) return null
|
||||
const d = hit.sub(center)
|
||||
return Math.atan2(d.dot(v), d.dot(u))
|
||||
}
|
||||
const startBearing = bearing(e.nativeEvent.clientX, e.nativeEvent.clientY)
|
||||
const startQuat = new Quaternion().setFromEuler(
|
||||
new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
|
||||
)
|
||||
let lastStep = Number.NaN
|
||||
return (event: PointerEvent): FittingTransform | null => {
|
||||
if (startBearing === null) return null
|
||||
const b = bearing(event.clientX, event.clientY)
|
||||
if (b === null) return null
|
||||
const raw = b - startBearing
|
||||
const delta = event.shiftKey ? raw : Math.round(raw / ROTATE_STEP_RAD) * ROTATE_STEP_RAD
|
||||
if (!event.shiftKey) {
|
||||
const step = Math.round(raw / ROTATE_STEP_RAD)
|
||||
if (step !== lastStep) {
|
||||
lastStep = step
|
||||
triggerSFX('sfx:item-rotate')
|
||||
}
|
||||
}
|
||||
const turn = new Quaternion().setFromAxisAngle(normal, delta)
|
||||
const euler = new Euler().setFromQuaternion(turn.multiply(startQuat))
|
||||
return { rotation: [euler.x, euler.y, euler.z] }
|
||||
}
|
||||
}
|
||||
|
||||
const beginDimensionDrag =
|
||||
(dimension: PipeDimension, axisLocal: Vector3, cursor: Cursor) =>
|
||||
(e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const baseValue = fitting[dimension]
|
||||
const initialPatch = dimensionPatch(fitting, dimension, baseValue)
|
||||
const centerWorld = toWorld(fitting.position as Point)
|
||||
const axisWorld = axisToWorld(fitting.position as Point, axisLocal)
|
||||
const start = sampleAxisParameter(
|
||||
e.nativeEvent.clientX,
|
||||
e.nativeEvent.clientY,
|
||||
centerWorld,
|
||||
axisWorld,
|
||||
)
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
setDragging(true)
|
||||
document.body.style.cursor = cursor
|
||||
let current: Partial<PipeFittingNode> | null = null
|
||||
let lastValue = Number.NaN
|
||||
|
||||
const apply = (patch: Partial<PipeFittingNode>) => {
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, patch),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const rawDeltaM =
|
||||
sampleAxisParameter(event.clientX, event.clientY, centerWorld, axisWorld) - start
|
||||
const deltaIn = (rawDeltaM / INCHES_TO_METERS) * 2
|
||||
const nextRaw = baseValue + deltaIn
|
||||
const nextValue = clamp(event.shiftKey ? nextRaw : snap(nextRaw, RESIZE_STEP_IN), 1.25, 8)
|
||||
if (nextValue === lastValue) return
|
||||
lastValue = nextValue
|
||||
current = dimensionPatch(fitting, dimension, nextValue)
|
||||
if (!event.shiftKey) triggerSFX('sfx:grid-snap')
|
||||
apply(current)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
setDragging(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
swallowNextClick()
|
||||
cleanup()
|
||||
apply(initialPatch)
|
||||
resumeSceneHistory(useScene)
|
||||
if (current) apply(current)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
const extent = useMemo(() => fittingExtentM(fitting), [fitting])
|
||||
const p = fitting.position as Point
|
||||
const base = extent + ARROW_GAP
|
||||
const fittingRotation = useMemo(
|
||||
() => new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
|
||||
[fitting.rotation],
|
||||
)
|
||||
const runDiameterAxis = useMemo(() => {
|
||||
const axis = new Vector3(0, 1, 0).applyEuler(fittingRotation).normalize()
|
||||
return axis.dot(UP) >= 0 ? axis : axis.multiplyScalar(-1)
|
||||
}, [fittingRotation])
|
||||
const baseBranchAxis = useMemo(
|
||||
() => new Vector3(0, 0, 1).applyEuler(fittingRotation).normalize(),
|
||||
[fittingRotation],
|
||||
)
|
||||
const branchAxis = useMemo(
|
||||
() => baseBranchAxis.clone().multiplyScalar(sideSign),
|
||||
[baseBranchAxis, sideSign],
|
||||
)
|
||||
useFrame(() => {
|
||||
if (!frame || fitting.fittingType === 'elbow') return
|
||||
const cameraPosition = camera.getWorldPosition(new Vector3())
|
||||
const cameraLocal = frame.worldToLocal(cameraPosition)
|
||||
const toCamera = cameraLocal.sub(new Vector3(p[0], p[1], p[2]))
|
||||
const nextSign = baseBranchAxis.dot(toCamera) >= 0 ? 1 : -1
|
||||
setSideSign((current) => (current === nextSign ? current : nextSign))
|
||||
})
|
||||
|
||||
const resizeHandleBase = extent + RESIZE_HANDLE_GAP
|
||||
const resizeHandles: {
|
||||
key: PipeDimension
|
||||
axis: Vector3
|
||||
cursor: Cursor
|
||||
guideFrom: Point
|
||||
guideTo: Point
|
||||
position: Point
|
||||
}[] = [
|
||||
{
|
||||
key: 'diameter',
|
||||
axis: runDiameterAxis,
|
||||
cursor: 'ns-resize',
|
||||
guideFrom: [
|
||||
p[0] + runDiameterAxis.x * resizeHandleBase,
|
||||
p[1] + runDiameterAxis.y * resizeHandleBase,
|
||||
p[2] + runDiameterAxis.z * resizeHandleBase,
|
||||
],
|
||||
guideTo: [
|
||||
p[0] + runDiameterAxis.x * Math.max(extent * 0.18, 0.04),
|
||||
p[1] + runDiameterAxis.y * Math.max(extent * 0.18, 0.04),
|
||||
p[2] + runDiameterAxis.z * Math.max(extent * 0.18, 0.04),
|
||||
],
|
||||
position: [
|
||||
p[0] + runDiameterAxis.x * resizeHandleBase,
|
||||
p[1] + runDiameterAxis.y * resizeHandleBase,
|
||||
p[2] + runDiameterAxis.z * resizeHandleBase,
|
||||
],
|
||||
},
|
||||
...(fitting.fittingType === 'elbow'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: 'diameter2' as const,
|
||||
axis: branchAxis,
|
||||
cursor: 'ew-resize' as Cursor,
|
||||
guideFrom: [
|
||||
p[0] + branchAxis.x * resizeHandleBase,
|
||||
p[1] + branchAxis.y * resizeHandleBase,
|
||||
p[2] + branchAxis.z * resizeHandleBase,
|
||||
] as Point,
|
||||
guideTo: [
|
||||
p[0] + branchAxis.x * Math.max(extent * 0.18, 0.04),
|
||||
p[1] + branchAxis.y * Math.max(extent * 0.18, 0.04),
|
||||
p[2] + branchAxis.z * Math.max(extent * 0.18, 0.04),
|
||||
] as Point,
|
||||
position: [
|
||||
p[0] + branchAxis.x * resizeHandleBase,
|
||||
p[1] + branchAxis.y * resizeHandleBase,
|
||||
p[2] + branchAxis.z * resizeHandleBase,
|
||||
] as Point,
|
||||
},
|
||||
]),
|
||||
]
|
||||
|
||||
const moveArrows: {
|
||||
key: string
|
||||
axis: RotationAxis
|
||||
position: Point
|
||||
rotationY: number
|
||||
vertical?: 'up' | 'down'
|
||||
cursor: Cursor
|
||||
}[] = [
|
||||
{ key: '+x', axis: 'x', position: [p[0] + base, p[1], p[2]], rotationY: 0, cursor: 'grab' },
|
||||
{
|
||||
key: '-x',
|
||||
axis: 'x',
|
||||
position: [p[0] - base, p[1], p[2]],
|
||||
rotationY: Math.PI,
|
||||
cursor: 'grab',
|
||||
},
|
||||
{
|
||||
key: '+z',
|
||||
axis: 'z',
|
||||
position: [p[0], p[1], p[2] + base],
|
||||
rotationY: -Math.PI / 2,
|
||||
cursor: 'grab',
|
||||
},
|
||||
{
|
||||
key: '-z',
|
||||
axis: 'z',
|
||||
position: [p[0], p[1], p[2] - base],
|
||||
rotationY: Math.PI / 2,
|
||||
cursor: 'grab',
|
||||
},
|
||||
{
|
||||
key: '+y',
|
||||
axis: 'y',
|
||||
position: [p[0], p[1] + base, p[2]],
|
||||
rotationY: 0,
|
||||
vertical: 'up',
|
||||
cursor: 'ns-resize',
|
||||
},
|
||||
{
|
||||
key: '-y',
|
||||
axis: 'y',
|
||||
position: [p[0], p[1] - base, p[2]],
|
||||
rotationY: 0,
|
||||
vertical: 'down',
|
||||
cursor: 'ns-resize',
|
||||
},
|
||||
]
|
||||
|
||||
const d = base * Math.SQRT1_2
|
||||
const rotateArcs: { key: string; axis: RotationAxis; position: Point; rotation: Point }[] = (
|
||||
['x', 'y', 'z'] as RotationAxis[]
|
||||
).map((axis) => {
|
||||
const q = new Quaternion().setFromUnitVectors(UP, AXIS_VECTORS[axis])
|
||||
if (axis === 'z') {
|
||||
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.z, Math.PI / 4))
|
||||
} else if (axis === 'x') {
|
||||
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.x, (-145 * Math.PI) / 180))
|
||||
} else if (axis === 'y') {
|
||||
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.y, (-45 * Math.PI) / 180))
|
||||
}
|
||||
const e = new Euler().setFromQuaternion(q)
|
||||
const position: Point =
|
||||
axis === 'x'
|
||||
? [p[0], p[1] + d, p[2] + d]
|
||||
: axis === 'y'
|
||||
? [p[0] + d, p[1], p[2] + d]
|
||||
: [p[0] + d, p[1] + d, p[2]]
|
||||
return { key: `r${axis}`, axis, position, rotation: [e.x, e.y, e.z] }
|
||||
})
|
||||
|
||||
if (dragging) return <group ref={setFrame} />
|
||||
return (
|
||||
<group ref={setFrame}>
|
||||
<HandleCube active={open} onClick={() => setOpen((o) => !o)} position={p} />
|
||||
{!open &&
|
||||
resizeHandles.map((handle) => (
|
||||
<group key={handle.key}>
|
||||
<DashedResizeGuide from={handle.guideFrom} to={handle.guideTo} />
|
||||
<ResizeSphereHandle
|
||||
cursor={handle.cursor}
|
||||
onPointerDown={beginDimensionDrag(handle.key, handle.axis, handle.cursor)}
|
||||
position={handle.position}
|
||||
/>
|
||||
</group>
|
||||
))}
|
||||
{open && (
|
||||
<>
|
||||
{moveArrows.map((a) => (
|
||||
<MoveChevron
|
||||
cursor={a.cursor}
|
||||
key={a.key}
|
||||
onPointerDown={beginDrag(
|
||||
a.axis === 'y' ? 'ns-resize' : 'grabbing',
|
||||
moveCompute(a.axis),
|
||||
)}
|
||||
position={a.position}
|
||||
rotationY={a.rotationY}
|
||||
vertical={a.vertical}
|
||||
/>
|
||||
))}
|
||||
{rotateArcs.map((arc) => (
|
||||
<RotateArc
|
||||
key={arc.key}
|
||||
onPointerDown={beginDrag('grabbing', rotateCompute(arc.axis))}
|
||||
position={arc.position}
|
||||
rotation={arc.rotation}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipeFittingSelectionAffordance
|
||||
|
||||
Reference in New Issue
Block a user