Merge origin/main into feat/placement-interaction-overhaul
Resolve 7 conflicts keeping our snapping migration + floorplan perf work as source of truth, combined with main's MEP run-continuation / Alt-detach / latch handles. Rebuilt two import blocks the auto-merge silently truncated (node-arrow-handles.tsx, duct-fitting/move-tool.tsx). Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass, biome clean. Floorplan view-transform re-render storm confirmed pre-existing (not introduced by this merge). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,7 @@ export const pipeFittingDefinition: NodeDefinition<typeof PipeFittingNode> = {
|
||||
// editor's SelectionAffordanceManager rather than `def.system`.
|
||||
affordanceTools: {
|
||||
selection: () => import('./selection'),
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
|
||||
tool: () => import('./tool'),
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
PipeFittingNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
DragBoundingBox,
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
stripPlacementMetadataFlags,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
|
||||
import {
|
||||
type Aabb2D,
|
||||
collectGhostAlignmentCandidates,
|
||||
resolveGhostAlignment,
|
||||
} from '../shared/ghost-alignment'
|
||||
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
||||
import { buildPipeFittingGeometry } from './geometry'
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
|
||||
const GHOST_COLOR = '#818cf8'
|
||||
const GHOST_OPACITY = 0.5
|
||||
|
||||
/** Screen pixels → meters for the Ctrl-vertical (riser) drag — matches the
|
||||
* pipe draw tool's Alt-vertical feel. 100 px ≈ 1 m. */
|
||||
const VERTICAL_PIXELS_PER_METER = 100
|
||||
const VERTICAL_Y_MIN_M = -3
|
||||
const VERTICAL_Y_MAX_M = 10
|
||||
|
||||
/** Snap a coordinate to the editor's live grid step. */
|
||||
function snapToGridStep(value: number): number {
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
/** World-space size + centre offset of `box` after the fitting's euler
|
||||
* rotation — the footprint box that wraps the oriented geometry. */
|
||||
function rotatedBounds(box: Box3, rotation: Vec3): { size: Vec3; offset: Vec3 } {
|
||||
const euler = new Euler(rotation[0], rotation[1], rotation[2])
|
||||
const min = box.min
|
||||
const max = box.max
|
||||
const corners: Vec3[] = [
|
||||
[min.x, min.y, min.z],
|
||||
[max.x, min.y, min.z],
|
||||
[min.x, max.y, min.z],
|
||||
[min.x, min.y, max.z],
|
||||
[max.x, max.y, min.z],
|
||||
[max.x, min.y, max.z],
|
||||
[min.x, max.y, max.z],
|
||||
[max.x, max.y, max.z],
|
||||
]
|
||||
const lo: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]
|
||||
const hi: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]
|
||||
const v = new Vector3()
|
||||
for (const c of corners) {
|
||||
v.set(c[0], c[1], c[2]).applyEuler(euler)
|
||||
lo[0] = Math.min(lo[0], v.x)
|
||||
lo[1] = Math.min(lo[1], v.y)
|
||||
lo[2] = Math.min(lo[2], v.z)
|
||||
hi[0] = Math.max(hi[0], v.x)
|
||||
hi[1] = Math.max(hi[1], v.y)
|
||||
hi[2] = Math.max(hi[2], v.z)
|
||||
}
|
||||
return {
|
||||
size: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]],
|
||||
offset: [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghost-preview duplicate / move tool for DWV pipe fittings (elbow / wye /
|
||||
* sanitary tee) — the plumbing sibling of the duct-fitting move tool.
|
||||
*
|
||||
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
|
||||
* inserted into the scene until the commit click. A translucent copy of the
|
||||
* fitting (built from its real geometry, at its own `rotation`, so an elbow
|
||||
* / riser stays properly aligned) rides the cursor inside a footprint
|
||||
* bounding box — the same affordance other items get — and Figma-style
|
||||
* alignment guides snap the box edges to nearby geometry. The commit click
|
||||
* calls `createNode`; Esc discards.
|
||||
*
|
||||
* **Move** (existing fitting): the real node is hidden while the ghost + box
|
||||
* track the cursor; commit writes the new `position` and reveals it.
|
||||
*
|
||||
* Modifiers (mirroring the duct-fitting move):
|
||||
* - **Alt** detaches: the connected-pipe follow drops so the fitting moves
|
||||
* on its own, leaving every mated run where it sits.
|
||||
* - **Ctrl / Cmd** switches to vertical movement (stack / riser editing): XZ
|
||||
* holds and the cursor's screen-Y drives the riser height.
|
||||
* - **Shift** bypasses grid snapping / alignment.
|
||||
*
|
||||
* Wired via `def.affordanceTools.move`.
|
||||
*/
|
||||
export const MovePipeFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
const fitting = node as PipeFittingNode
|
||||
const originalPosition = (fitting.position ?? [0, 0, 0]) as Vec3
|
||||
const rotation = (fitting.rotation ?? [0, 0, 0]) as Vec3
|
||||
const isNew =
|
||||
typeof node.metadata === 'object' &&
|
||||
node.metadata !== null &&
|
||||
!Array.isArray(node.metadata) &&
|
||||
(node.metadata as Record<string, unknown>).isNew === true
|
||||
|
||||
const [cursorPos, setCursorPos] = useState<Vec3>(originalPosition)
|
||||
|
||||
// Translucent stand-in built from the fitting's real geometry. Rotation is
|
||||
// a geometry input (it decides the elbow's profile roles), so the ghost
|
||||
// matches what lands. Rebuilt only if the source changes.
|
||||
const ghost = useMemo(() => {
|
||||
const group = buildPipeFittingGeometry(fitting)
|
||||
group.traverse((obj) => {
|
||||
const mesh = obj as Mesh
|
||||
if ((mesh as { isMesh?: boolean }).isMesh) {
|
||||
mesh.material = new MeshBasicMaterial({
|
||||
color: GHOST_COLOR,
|
||||
transparent: true,
|
||||
opacity: GHOST_OPACITY,
|
||||
depthTest: false,
|
||||
})
|
||||
mesh.renderOrder = 999
|
||||
}
|
||||
obj.layers.set(EDITOR_LAYER)
|
||||
})
|
||||
return group
|
||||
}, [fitting])
|
||||
|
||||
// Footprint box that wraps the oriented geometry (size + centre offset),
|
||||
// measured once from the ghost.
|
||||
const bounds = useMemo(() => {
|
||||
const box = new Box3().setFromObject(ghost)
|
||||
if (box.isEmpty()) return { size: [0.3, 0.3, 0.3] as Vec3, offset: [0, 0, 0] as Vec3 }
|
||||
return rotatedBounds(box, rotation)
|
||||
}, [ghost, rotation])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
ghost.traverse((obj) => {
|
||||
const mesh = obj as Mesh
|
||||
if ((mesh as { isMesh?: boolean }).isMesh) {
|
||||
mesh.geometry?.dispose?.()
|
||||
const mat = mesh.material as Material | Material[]
|
||||
if (Array.isArray(mat)) for (const m of mat) m.dispose?.()
|
||||
else mat?.dispose?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [ghost])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = node.id as AnyNodeId
|
||||
const [hx, , hz] = [bounds.size[0] / 2, 0, bounds.size[2] / 2]
|
||||
const [ox, , oz] = bounds.offset
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let committed = false
|
||||
let hasMoved = false
|
||||
const activatedAt = Date.now()
|
||||
|
||||
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
|
||||
useScene.getState().nodes,
|
||||
nodeId,
|
||||
useViewer.getState().selection.levelId ?? node.parentId,
|
||||
)
|
||||
|
||||
// Moving an existing fitting: hide its 3D MESH imperatively (NOT the
|
||||
// store `visible` flag — the 2D floor plan skips `visible:false` nodes,
|
||||
// so a store hide makes it vanish in 2D / split view). The ghost stands
|
||||
// in until commit; the real mesh is restored on cancel / unmount.
|
||||
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
|
||||
const setMeshHidden = (hidden: boolean) => {
|
||||
const obj = sceneRegistry.nodes.get(nodeId)
|
||||
if (obj) obj.visible = !hidden
|
||||
}
|
||||
if (existedAtStart) setMeshHidden(true)
|
||||
|
||||
// Carry connected pipes as the fitting slides: the part of the move along
|
||||
// a run's axis stretches it, the part across translates the whole run (and
|
||||
// propagates to its far joint). Snapshot once at drag start; only existing
|
||||
// fittings are mated to anything.
|
||||
const connectivity: RunMoveConnectivity | null = existedAtStart
|
||||
? startRunMoveConnectivity(node)
|
||||
: null
|
||||
|
||||
let lastPos: Vec3 = originalPosition
|
||||
// Tracks whether the last frame held Alt: the fitting is detached from its
|
||||
// connected pipes for the drag, so they stay put (no follow) and the
|
||||
// commit omits their updates. Mirrors the pipe endpoint's Alt-detach.
|
||||
let lastDetached = false
|
||||
// Anchor for the Ctrl-vertical (riser) drag: clientY + base Y captured the
|
||||
// frame Ctrl is first held, so vertical mouse motion maps to Y. Cleared
|
||||
// when Ctrl is released. Mirrors the draw tool's Alt-vertical anchor.
|
||||
let verticalAnchor: { clientY: number; baseY: number } | null = null
|
||||
|
||||
const onMove = (event: GridEvent) => {
|
||||
const bypass = event.nativeEvent?.shiftKey === true
|
||||
// Alt = detach: drop the connected-pipe follow so the fitting moves on
|
||||
// its own, leaving every mated run where it sits.
|
||||
const detached = event.nativeEvent?.altKey === true
|
||||
// Ctrl/Cmd = vertical: XZ locks to where the fitting sits and the cursor's
|
||||
// screen-Y drives the riser height (connected pipes still follow).
|
||||
const vertical = event.nativeEvent?.ctrlKey === true || event.nativeEvent?.metaKey === true
|
||||
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
|
||||
const snap = bypass ? (v: number) => v : snapToGridStep
|
||||
|
||||
let next: Vec3
|
||||
if (vertical && typeof clientY === 'number') {
|
||||
if (!verticalAnchor) verticalAnchor = { clientY, baseY: lastPos[1] }
|
||||
// Screen +Y points down, so subtract to map "drag up = raise".
|
||||
const dy = (verticalAnchor.clientY - clientY) / VERTICAL_PIXELS_PER_METER
|
||||
const y = Math.min(
|
||||
VERTICAL_Y_MAX_M,
|
||||
Math.max(VERTICAL_Y_MIN_M, verticalAnchor.baseY + snap(dy)),
|
||||
)
|
||||
next = [lastPos[0], y, lastPos[2]]
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
verticalAnchor = null
|
||||
let x = snap(event.localPosition[0])
|
||||
let z = snap(event.localPosition[2])
|
||||
|
||||
// Alignment: snap the footprint box edges onto nearby geometry and
|
||||
// publish guides (Alt / Shift bypass).
|
||||
if (!bypass) {
|
||||
const proposed: Aabb2D = {
|
||||
minX: x + ox - hx,
|
||||
maxX: x + ox + hx,
|
||||
minZ: z + oz - hz,
|
||||
maxZ: z + oz + hz,
|
||||
}
|
||||
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
||||
x += dx
|
||||
z += dz
|
||||
useAlignmentGuides.getState().set(guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
next = [x, lastPos[1], z]
|
||||
}
|
||||
|
||||
if (next[0] !== lastPos[0] || next[1] !== lastPos[1] || next[2] !== lastPos[2]) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
lastPos = next
|
||||
lastDetached = detached
|
||||
hasMoved = true
|
||||
setCursorPos(next)
|
||||
// Detached: keep the followers at their origin (drop any live overrides
|
||||
// from a prior non-detached frame). Otherwise preview the follow.
|
||||
if (detached) connectivity?.clear()
|
||||
else connectivity?.preview({ position: next })
|
||||
}
|
||||
|
||||
const commit = (event: GridEvent) => {
|
||||
if (committed) return
|
||||
if (Date.now() - activatedAt < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
if (!hasMoved) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
let selectId = nodeId
|
||||
if (isNew && !useScene.getState().nodes[nodeId]) {
|
||||
const created = PipeFittingNode.parse({
|
||||
...(node as Record<string, unknown>),
|
||||
position: lastPos,
|
||||
metadata: stripPlacementMetadataFlags(node.metadata),
|
||||
visible: true,
|
||||
})
|
||||
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
|
||||
selectId = created.id as AnyNodeId
|
||||
} else {
|
||||
// Fold connected-pipe / sibling-run follow-updates into the SAME batch
|
||||
// as the moved fitting so the whole joint is one undo step. Detached
|
||||
// (Alt on the final frame): the joint is broken, so nothing follows.
|
||||
const followUpdates = lastDetached
|
||||
? []
|
||||
: (connectivity?.commitUpdates({ position: lastPos }) ?? [])
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([
|
||||
{ id: nodeId, data: { position: lastPos } as Partial<AnyNode> },
|
||||
...followUpdates,
|
||||
])
|
||||
useScene.getState().markDirty(nodeId)
|
||||
}
|
||||
useScene.temporal.getState().pause()
|
||||
// Followers are committed to the store — drop their live overrides so
|
||||
// renderers read the canonical path/position.
|
||||
connectivity?.clear()
|
||||
setMeshHidden(false)
|
||||
|
||||
useAlignmentGuides.getState().clear()
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [selectId] })
|
||||
useEditor.getState().setMovingNodeOrigin('3d')
|
||||
useEditor.getState().setMovingNode(null)
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
}
|
||||
useAlignmentGuides.getState().clear()
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
useEditor.getState().setMovingNodeOrigin('3d')
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onMove)
|
||||
emitter.on('grid:click', commit)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', commit)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
connectivity?.clear()
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (existedAtStart) setMeshHidden(false)
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [bounds, isNew, node, originalPosition])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<primitive object={ghost} position={cursorPos} rotation={rotation} />
|
||||
<DragBoundingBox
|
||||
centerY={bounds.offset[1]}
|
||||
nodeId={node.id}
|
||||
position={[cursorPos[0] + bounds.offset[0], cursorPos[1], cursorPos[2] + bounds.offset[2]]}
|
||||
size={bounds.size}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default MovePipeFittingTool
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, type AnyNodeId, PipeFittingNode, PipeSegmentNode } from '@pascal-app/core'
|
||||
import { pipeFittingParametrics } from './parametrics'
|
||||
import { getPipeFittingPorts } from './ports'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
function pipeElbow() {
|
||||
return PipeFittingNode.parse({
|
||||
id: 'pipe-fitting_elbow' as AnyNodeId,
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'DWV bend',
|
||||
fittingType: 'elbow',
|
||||
angle: 90,
|
||||
diameter: 3,
|
||||
diameter2: 3,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
})
|
||||
}
|
||||
|
||||
function pipe(id: string, path: Point[]) {
|
||||
return PipeSegmentNode.parse({
|
||||
id: id as AnyNodeId,
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'DWV pipe',
|
||||
path,
|
||||
diameter: 3,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
})
|
||||
}
|
||||
|
||||
function add(point: readonly number[], dir: readonly number[], length: number): Point {
|
||||
return [point[0]! + dir[0]! * length, point[1]! + dir[1]! * length, point[2]! + dir[2]! * length]
|
||||
}
|
||||
|
||||
describe('pipeFittingParametrics', () => {
|
||||
test('deleting an elbow re-extends mated pipe ends back onto the junction', () => {
|
||||
const fitting = pipeElbow()
|
||||
const inlet = getPipeFittingPorts(fitting).find((p) => p.id === 'inlet')!
|
||||
const outlet = getPipeFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const inletRun = pipe('pipe-segment_inlet', [
|
||||
add(inlet.position, inlet.direction, 3),
|
||||
[...inlet.position] as Point,
|
||||
])
|
||||
const outletRun = pipe('pipe-segment_outlet', [
|
||||
[...outlet.position] as Point,
|
||||
add(outlet.position, outlet.direction, 3),
|
||||
])
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
[outletRun.id]: outletRun as AnyNode,
|
||||
}
|
||||
|
||||
const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
const inletUpdate = updates.find((u) => u.id === inletRun.id)
|
||||
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
||||
|
||||
expect((inletUpdate?.data as Partial<PipeSegmentNode>).path?.[1]).toEqual([...fitting.position])
|
||||
expect((outletUpdate?.data as Partial<PipeSegmentNode>).path?.[0]).toEqual([
|
||||
...fitting.position,
|
||||
])
|
||||
})
|
||||
|
||||
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
|
||||
const fitting = pipeElbow()
|
||||
const inlet = getPipeFittingPorts(fitting).find((p) => p.id === 'inlet')!
|
||||
const inletRun = pipe('pipe-segment_inlet', [
|
||||
add(inlet.position, inlet.direction, 3),
|
||||
add(inlet.position, [0, 0, 1], 0.04),
|
||||
])
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
}
|
||||
|
||||
const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
|
||||
expect((updates[0]?.data as Partial<PipeSegmentNode>).path?.[1]).toEqual([...fitting.position])
|
||||
})
|
||||
|
||||
test('deleting a branch fitting leaves mated pipe ends untouched', () => {
|
||||
const wye = PipeFittingNode.parse({ ...pipeElbow(), fittingType: 'wye' })
|
||||
const inlet = getPipeFittingPorts(wye).find((p) => p.id === 'inlet')!
|
||||
const inletRun = pipe('pipe-segment_inlet', [
|
||||
add(inlet.position, inlet.direction, 3),
|
||||
[...inlet.position] as Point,
|
||||
])
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[wye.id]: wye as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
}
|
||||
|
||||
expect(pipeFittingParametrics.onDelete?.(wye, nodes) ?? []).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,62 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { AnyNode, AnyNodeId, ParametricDescriptor, PipeSegmentNode } from '@pascal-app/core'
|
||||
import { getPipeFittingPorts } from './ports'
|
||||
import type { PipeFittingNode } from './schema'
|
||||
|
||||
/** A pipe endpoint sitting this close to a fitting hub counts as mated. */
|
||||
const MATE_TOL_M = 0.05
|
||||
|
||||
type Point = [number, number, number]
|
||||
type PipeMate = { pipe: PipeSegmentNode; endIndex: number }
|
||||
|
||||
function matedPipes(
|
||||
fitting: PipeFittingNode,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): Map<string, PipeMate> {
|
||||
const mates = new Map<string, PipeMate>()
|
||||
const ports = getPipeFittingPorts(fitting)
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'pipe-segment') continue
|
||||
const pipe = node as PipeSegmentNode
|
||||
for (const endIndex of [0, pipe.path.length - 1]) {
|
||||
const p = pipe.path[endIndex]
|
||||
if (!p) continue
|
||||
for (const port of ports) {
|
||||
if (mates.has(port.id)) continue
|
||||
const dx = p[0] - port.position[0]
|
||||
const dy = p[1] - port.position[1]
|
||||
const dz = p[2] - port.position[2]
|
||||
if (dx * dx + dy * dy + dz * dz <= MATE_TOL_M * MATE_TOL_M) {
|
||||
mates.set(port.id, { pipe, endIndex })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return mates
|
||||
}
|
||||
|
||||
export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
|
||||
// Deleting an auto-inserted DWV bend restores the corner it replaced.
|
||||
// The connected pipe endpoints were pulled back onto the bend collars;
|
||||
// send those endpoints back to the junction so the L-shape regains its
|
||||
// original length.
|
||||
onDelete: (fitting, nodes) => {
|
||||
if (fitting.fittingType !== 'elbow') return []
|
||||
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
||||
for (const mate of matedPipes(fitting, nodes).values()) {
|
||||
const end = mate.pipe.path[mate.endIndex]
|
||||
if (!end) continue
|
||||
const target = fitting.position
|
||||
const dx = end[0] - target[0]
|
||||
const dy = end[1] - target[1]
|
||||
const dz = end[2] - target[2]
|
||||
if (dx * dx + dy * dy + dz * dz < 1e-12) continue
|
||||
const path = mate.pipe.path.map((p) => [...p] as Point)
|
||||
path[mate.endIndex] = [...target]
|
||||
updates.push({ id: mate.pipe.id, data: { path } as Partial<PipeSegmentNode> })
|
||||
}
|
||||
return updates
|
||||
},
|
||||
|
||||
groups: [
|
||||
{
|
||||
label: 'Fitting',
|
||||
@@ -16,7 +71,7 @@ export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
|
||||
key: 'angle',
|
||||
kind: 'number',
|
||||
unit: '°',
|
||||
min: 15,
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 7.5,
|
||||
visibleIf: (n) => n.fittingType === 'elbow',
|
||||
|
||||
@@ -1,27 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
analyzePortConnectivity,
|
||||
type Cursor,
|
||||
type PipeFittingNode,
|
||||
type PortConnectivity,
|
||||
pauseSceneHistory,
|
||||
resolveConnectivityUpdates,
|
||||
resumeSceneHistory,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ARROW_COLOR,
|
||||
EDITOR_LAYER,
|
||||
swallowNextClick,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { cycleRotationAxis } from '../shared/fitting-rotation'
|
||||
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
LineSegments,
|
||||
type Object3D,
|
||||
OrthographicCamera,
|
||||
Plane,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
SphereGeometry,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
AXIS_VECTORS,
|
||||
cycleRotationAxis,
|
||||
ROTATE_STEP_RAD,
|
||||
type RotationAxis,
|
||||
} from '../shared/fitting-rotation'
|
||||
import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles'
|
||||
import { pipeFittingLegLength } from './ports'
|
||||
|
||||
type Point = [number, number, number]
|
||||
type FittingTransform = { position?: Point; rotation?: Point }
|
||||
type PipeDimension = 'diameter' | 'diameter2'
|
||||
|
||||
const ARROW_GAP = 0.34
|
||||
const RESIZE_HANDLE_GAP = 0.3
|
||||
const RESIZE_STEP_IN = 0.25
|
||||
const RESIZE_GUIDE_DASH = 0.07
|
||||
const RESIZE_GUIDE_GAP = 0.045
|
||||
const RESIZE_SPHERE_RADIUS = 0.065
|
||||
const RESIZE_HIT_RADIUS = 0.13
|
||||
const INCHES_TO_METERS = 0.0254
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function fittingExtentM(node: PipeFittingNode): number {
|
||||
return Math.max(pipeFittingLegLength(node.diameter), pipeFittingLegLength(node.diameter2))
|
||||
}
|
||||
|
||||
function fittingParameterPatch(node: PipeFittingNode): Partial<PipeFittingNode> {
|
||||
return {
|
||||
fittingType: node.fittingType,
|
||||
angle: node.angle,
|
||||
diameter: node.diameter,
|
||||
diameter2: node.diameter2,
|
||||
pipeMaterial: node.pipeMaterial,
|
||||
system: node.system,
|
||||
}
|
||||
}
|
||||
|
||||
function preserveFittingParameters(
|
||||
node: PipeFittingNode,
|
||||
data: Partial<PipeFittingNode>,
|
||||
): Partial<AnyNode> {
|
||||
return { ...fittingParameterPatch(node), ...data } as Partial<AnyNode>
|
||||
}
|
||||
|
||||
function dimensionPatch(
|
||||
fitting: PipeFittingNode,
|
||||
dimension: PipeDimension,
|
||||
value: number,
|
||||
): Partial<PipeFittingNode> {
|
||||
if (dimension === 'diameter' && fitting.fittingType === 'elbow') {
|
||||
return { diameter: value, diameter2: value }
|
||||
}
|
||||
return { [dimension]: value } as Partial<PipeFittingNode>
|
||||
}
|
||||
|
||||
function closestAxisParameterToRay(
|
||||
axisOrigin: Vector3,
|
||||
axisDirection: Vector3,
|
||||
ray: Raycaster['ray'],
|
||||
) {
|
||||
const originToRay = axisOrigin.clone().sub(ray.origin)
|
||||
const b = axisDirection.dot(ray.direction)
|
||||
const d = axisDirection.dot(originToRay)
|
||||
const e = ray.direction.dot(originToRay)
|
||||
const denominator = 1 - b * b
|
||||
if (Math.abs(denominator) < 1e-6) return -d
|
||||
const axisParameter = (b * e - d) / denominator
|
||||
const rayParameter = e + b * axisParameter
|
||||
return rayParameter < 0 ? -d : axisParameter
|
||||
}
|
||||
|
||||
function DashedResizeGuide({ from, to }: { from: Point; to: Point }) {
|
||||
const line = useMemo(() => {
|
||||
const a = new Vector3(from[0], from[1], from[2])
|
||||
const b = new Vector3(to[0], to[1], to[2])
|
||||
const span = b.clone().sub(a)
|
||||
const length = span.length()
|
||||
const points: number[] = []
|
||||
if (length > 1e-4) {
|
||||
const dir = span.clone().normalize()
|
||||
let t = 0
|
||||
while (t < length) {
|
||||
const start = a.clone().addScaledVector(dir, t)
|
||||
const end = a.clone().addScaledVector(dir, Math.min(t + RESIZE_GUIDE_DASH, length))
|
||||
points.push(start.x, start.y, start.z, end.x, end.y, end.z)
|
||||
t += RESIZE_GUIDE_DASH + RESIZE_GUIDE_GAP
|
||||
}
|
||||
}
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(points), 3))
|
||||
const material = new LineBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
depthWrite: false,
|
||||
})
|
||||
const next = new LineSegments(geometry, material)
|
||||
next.frustumCulled = false
|
||||
next.layers.set(EDITOR_LAYER)
|
||||
next.renderOrder = 1002
|
||||
next.raycast = () => {}
|
||||
return next
|
||||
}, [from, to])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
line.geometry.dispose()
|
||||
;(line.material as LineBasicNodeMaterial).dispose()
|
||||
},
|
||||
[line],
|
||||
)
|
||||
|
||||
return <primitive object={line} />
|
||||
}
|
||||
|
||||
function ResizeSphereHandle({
|
||||
cursor,
|
||||
onPointerDown,
|
||||
position,
|
||||
}: {
|
||||
cursor: Cursor
|
||||
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||
position: Point
|
||||
}) {
|
||||
const { camera } = useThree()
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const sphereGeometry = useMemo(() => new SphereGeometry(RESIZE_SPHERE_RADIUS, 18, 12), [])
|
||||
const hitGeometry = useMemo(() => new SphereGeometry(RESIZE_HIT_RADIUS, 12, 8), [])
|
||||
const sphereMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.92,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const hitMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
sphereMaterial.opacity = hovered ? 1 : 0.92
|
||||
}, [sphereMaterial, hovered])
|
||||
useEffect(
|
||||
() => () => {
|
||||
hitGeometry.dispose()
|
||||
sphereGeometry.dispose()
|
||||
sphereMaterial.dispose()
|
||||
hitMaterial.dispose()
|
||||
},
|
||||
[hitGeometry, hitMaterial, sphereGeometry, sphereMaterial],
|
||||
)
|
||||
|
||||
const consumePress = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.stopPropagation()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
swallowNextClick()
|
||||
onPointerDown(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={position} scale={zoom}>
|
||||
<mesh
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={consumePress}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
setHovered(true)
|
||||
document.body.style.cursor = cursor
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
event.stopPropagation()
|
||||
setHovered(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}}
|
||||
/>
|
||||
<mesh geometry={sphereGeometry} material={sphereMaterial} renderOrder={1004} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-time rotation support for placed pipe fittings — mirrors
|
||||
* the duct-fitting affordance, mounted by the editor's
|
||||
* SelectionAffordanceManager (`def.affordanceTools.selection`). R/T
|
||||
* rotation lives in `def.keyboardActions`; this contributes the piece
|
||||
* that hook can't: **Alt cycles the active rotation axis** while a
|
||||
* single fitting is selected. The axis lives on `useEditor.rotationAxis`,
|
||||
* which the floating action menu reads to show the axis pill — so this
|
||||
* component renders nothing.
|
||||
*/
|
||||
const PipeFittingSelectionAffordance = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const hasSelectedFitting = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return false
|
||||
return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'pipe-fitting'
|
||||
const fitting = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
||||
return node?.type === 'pipe-fitting' ? (node as PipeFittingNode) : null
|
||||
})
|
||||
|
||||
const hasSelectedFitting = !!fitting
|
||||
useEffect(() => {
|
||||
if (!hasSelectedFitting) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -31,13 +263,527 @@ const PipeFittingSelectionAffordance = () => {
|
||||
e.preventDefault()
|
||||
cycleRotationAxis()
|
||||
}
|
||||
// Bubble phase — when the placement tool is active its capture-phase
|
||||
// handler stops propagation, so the two never double-cycle.
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [hasSelectedFitting])
|
||||
|
||||
return null
|
||||
const fittingId = fitting?.id ?? null
|
||||
const [target, setTarget] = useState<Object3D | null>(null)
|
||||
useEffect(() => {
|
||||
if (!fittingId) {
|
||||
setTarget(null)
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(fittingId as AnyNodeId) ?? null
|
||||
setTarget((cur) => (cur === next ? cur : next))
|
||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
resolve()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [fittingId])
|
||||
|
||||
if (!fitting || !target) return null
|
||||
const mount = target.parent ?? target
|
||||
return createPortal(<FittingHandles fitting={fitting} />, mount, undefined)
|
||||
}
|
||||
|
||||
const FittingHandles = ({ fitting }: { fitting: PipeFittingNode }) => {
|
||||
const { camera, gl } = useThree()
|
||||
const [frame, setFrame] = useState<Group | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [sideSign, setSideSign] = useState(1)
|
||||
|
||||
const makeRay = (clientX: number, clientY: number) => {
|
||||
const rect = gl.domElement.getBoundingClientRect()
|
||||
const ndc = new Vector2(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
const raycaster = new Raycaster()
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
return raycaster.ray
|
||||
}
|
||||
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
|
||||
const hit = new Vector3()
|
||||
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
|
||||
}
|
||||
const sampleAxisParameter = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
axisOrigin: Vector3,
|
||||
axisDirection: Vector3,
|
||||
): number => closestAxisParameterToRay(axisOrigin, axisDirection, makeRay(clientX, clientY))
|
||||
const intersectVerticalY = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
anchorWorld: Vector3,
|
||||
): number | null => {
|
||||
if (!frame) return null
|
||||
const forward = camera.getWorldDirection(new Vector3())
|
||||
forward.y = 0
|
||||
if (forward.lengthSq() < 1e-6) forward.set(0, 0, 1)
|
||||
forward.normalize()
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(forward, anchorWorld)
|
||||
const hit = intersect(clientX, clientY, plane)
|
||||
return hit ? frame.worldToLocal(hit.clone()).y : null
|
||||
}
|
||||
|
||||
const toWorld = (p: Point): Vector3 =>
|
||||
frame ? frame.localToWorld(new Vector3(p[0], p[1], p[2])) : new Vector3(p[0], p[1], p[2])
|
||||
const axisToWorld = (origin: Point, axis: Vector3): Vector3 => {
|
||||
const originWorld = toWorld(origin)
|
||||
const tipWorld = frame
|
||||
? frame.localToWorld(new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z))
|
||||
: new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z)
|
||||
return tipWorld.sub(originWorld).normalize()
|
||||
}
|
||||
|
||||
const sampleAxis = (
|
||||
axis: RotationAxis,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
anchorWorld: Vector3,
|
||||
): number | null => {
|
||||
if (axis === 'y') return intersectVerticalY(clientX, clientY, anchorWorld)
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, anchorWorld)
|
||||
const hit = intersect(clientX, clientY, plane)
|
||||
if (!hit || !frame) return null
|
||||
const local = frame.worldToLocal(hit.clone())
|
||||
return axis === 'x' ? local.x : local.z
|
||||
}
|
||||
|
||||
const connectivityUpdates = (
|
||||
connectivity: PortConnectivity | null,
|
||||
transform: FittingTransform,
|
||||
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
||||
if (!connectivity) return []
|
||||
const preview = { ...(fitting as Record<string, unknown>), ...transform } as AnyNode
|
||||
const nodes = useScene.getState().nodes
|
||||
return resolveConnectivityUpdates(connectivity, preview)
|
||||
.filter((u) => nodes[u.id])
|
||||
.map((u) => {
|
||||
const node = nodes[u.id]
|
||||
if (node?.type !== 'pipe-fitting') return u
|
||||
return {
|
||||
id: u.id,
|
||||
data: preserveFittingParameters(
|
||||
node as PipeFittingNode,
|
||||
u.data as Partial<PipeFittingNode>,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const beginDrag =
|
||||
(
|
||||
cursor: Cursor,
|
||||
makeCompute: (
|
||||
e: ThreeEvent<PointerEvent>,
|
||||
) => (event: PointerEvent) => FittingTransform | null,
|
||||
) =>
|
||||
(e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const initialPosition = [...fitting.position] as Point
|
||||
const initialRotation = [...fitting.rotation] as Point
|
||||
const connectivity = analyzePortConnectivity(fitting as AnyNode, useScene.getState().nodes)
|
||||
const compute = makeCompute(e)
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
setDragging(true)
|
||||
document.body.style.cursor = cursor
|
||||
let current: FittingTransform | null = null
|
||||
|
||||
const buildBatch = (t: FittingTransform): { id: AnyNodeId; data: Partial<AnyNode> }[] => [
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, t as Partial<PipeFittingNode>),
|
||||
},
|
||||
...connectivityUpdates(connectivity, t),
|
||||
]
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const next = compute(event)
|
||||
if (!next) return
|
||||
current = next
|
||||
useScene.getState().updateNodes(buildBatch(next))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
setDragging(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
swallowNextClick()
|
||||
cleanup()
|
||||
const reverts: { id: AnyNodeId; data: Partial<AnyNode> }[] = (
|
||||
connectivity?.connections ?? []
|
||||
).map((conn) => {
|
||||
if (conn.kind !== 'rigid-node') {
|
||||
return { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }
|
||||
}
|
||||
const node = useScene.getState().nodes[conn.nodeId]
|
||||
return {
|
||||
id: conn.nodeId,
|
||||
data:
|
||||
node?.type === 'pipe-fitting'
|
||||
? preserveFittingParameters(node as PipeFittingNode, {
|
||||
position: conn.startPosition as Point,
|
||||
})
|
||||
: ({ position: conn.startPosition } as Partial<AnyNode>),
|
||||
}
|
||||
})
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, {
|
||||
position: initialPosition,
|
||||
rotation: initialRotation,
|
||||
}),
|
||||
},
|
||||
...reverts.filter((u) => useScene.getState().nodes[u.id]),
|
||||
])
|
||||
resumeSceneHistory(useScene)
|
||||
if (current) useScene.getState().updateNodes(buildBatch(current))
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
const moveCompute =
|
||||
(axis: RotationAxis) =>
|
||||
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
|
||||
const anchorWorld = toWorld(fitting.position as Point)
|
||||
const start = sampleAxis(axis, e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld)
|
||||
const base = [...fitting.position] as Point
|
||||
const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
||||
let lastDelta = Number.NaN
|
||||
return (event: PointerEvent): FittingTransform | null => {
|
||||
if (start === null) return null
|
||||
const s = sampleAxis(axis, event.clientX, event.clientY, anchorWorld)
|
||||
if (s === null) return null
|
||||
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
||||
const delta = snap(s - start, step)
|
||||
if (delta === lastDelta) return null
|
||||
lastDelta = delta
|
||||
if (step > 0) triggerSFX('sfx:grid-snap')
|
||||
const next = [...base] as Point
|
||||
next[axisIndex] = base[axisIndex] + delta
|
||||
return { position: next }
|
||||
}
|
||||
}
|
||||
|
||||
const rotateCompute =
|
||||
(axis: RotationAxis) =>
|
||||
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
|
||||
const normal = AXIS_VECTORS[axis].clone()
|
||||
const center = toWorld(fitting.position as Point)
|
||||
const ref = axis === 'y' ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0)
|
||||
const u = ref
|
||||
.clone()
|
||||
.sub(normal.clone().multiplyScalar(ref.dot(normal)))
|
||||
.normalize()
|
||||
const v = new Vector3().crossVectors(normal, u)
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center)
|
||||
const bearing = (clientX: number, clientY: number): number | null => {
|
||||
const hit = intersect(clientX, clientY, plane)
|
||||
if (!hit) return null
|
||||
const d = hit.sub(center)
|
||||
return Math.atan2(d.dot(v), d.dot(u))
|
||||
}
|
||||
const startBearing = bearing(e.nativeEvent.clientX, e.nativeEvent.clientY)
|
||||
const startQuat = new Quaternion().setFromEuler(
|
||||
new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
|
||||
)
|
||||
let lastStep = Number.NaN
|
||||
return (event: PointerEvent): FittingTransform | null => {
|
||||
if (startBearing === null) return null
|
||||
const b = bearing(event.clientX, event.clientY)
|
||||
if (b === null) return null
|
||||
const raw = b - startBearing
|
||||
const delta = event.shiftKey ? raw : Math.round(raw / ROTATE_STEP_RAD) * ROTATE_STEP_RAD
|
||||
if (!event.shiftKey) {
|
||||
const step = Math.round(raw / ROTATE_STEP_RAD)
|
||||
if (step !== lastStep) {
|
||||
lastStep = step
|
||||
triggerSFX('sfx:item-rotate')
|
||||
}
|
||||
}
|
||||
const turn = new Quaternion().setFromAxisAngle(normal, delta)
|
||||
const euler = new Euler().setFromQuaternion(turn.multiply(startQuat))
|
||||
return { rotation: [euler.x, euler.y, euler.z] }
|
||||
}
|
||||
}
|
||||
|
||||
const beginDimensionDrag =
|
||||
(dimension: PipeDimension, axisLocal: Vector3, cursor: Cursor) =>
|
||||
(e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const baseValue = fitting[dimension]
|
||||
const initialPatch = dimensionPatch(fitting, dimension, baseValue)
|
||||
const centerWorld = toWorld(fitting.position as Point)
|
||||
const axisWorld = axisToWorld(fitting.position as Point, axisLocal)
|
||||
const start = sampleAxisParameter(
|
||||
e.nativeEvent.clientX,
|
||||
e.nativeEvent.clientY,
|
||||
centerWorld,
|
||||
axisWorld,
|
||||
)
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
setDragging(true)
|
||||
document.body.style.cursor = cursor
|
||||
let current: Partial<PipeFittingNode> | null = null
|
||||
let lastValue = Number.NaN
|
||||
|
||||
const apply = (patch: Partial<PipeFittingNode>) => {
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, patch),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const rawDeltaM =
|
||||
sampleAxisParameter(event.clientX, event.clientY, centerWorld, axisWorld) - start
|
||||
const deltaIn = (rawDeltaM / INCHES_TO_METERS) * 2
|
||||
const nextRaw = baseValue + deltaIn
|
||||
const nextValue = clamp(event.shiftKey ? nextRaw : snap(nextRaw, RESIZE_STEP_IN), 1.25, 8)
|
||||
if (nextValue === lastValue) return
|
||||
lastValue = nextValue
|
||||
current = dimensionPatch(fitting, dimension, nextValue)
|
||||
if (!event.shiftKey) triggerSFX('sfx:grid-snap')
|
||||
apply(current)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
setDragging(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
swallowNextClick()
|
||||
cleanup()
|
||||
apply(initialPatch)
|
||||
resumeSceneHistory(useScene)
|
||||
if (current) apply(current)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
const extent = useMemo(() => fittingExtentM(fitting), [fitting])
|
||||
const p = fitting.position as Point
|
||||
const base = extent + ARROW_GAP
|
||||
const fittingRotation = useMemo(
|
||||
() => new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
|
||||
[fitting.rotation],
|
||||
)
|
||||
const runDiameterAxis = useMemo(() => {
|
||||
const axis = new Vector3(0, 1, 0).applyEuler(fittingRotation).normalize()
|
||||
return axis.dot(UP) >= 0 ? axis : axis.multiplyScalar(-1)
|
||||
}, [fittingRotation])
|
||||
const baseBranchAxis = useMemo(
|
||||
() => new Vector3(0, 0, 1).applyEuler(fittingRotation).normalize(),
|
||||
[fittingRotation],
|
||||
)
|
||||
const branchAxis = useMemo(
|
||||
() => baseBranchAxis.clone().multiplyScalar(sideSign),
|
||||
[baseBranchAxis, sideSign],
|
||||
)
|
||||
useFrame(() => {
|
||||
if (!frame || fitting.fittingType === 'elbow') return
|
||||
const cameraPosition = camera.getWorldPosition(new Vector3())
|
||||
const cameraLocal = frame.worldToLocal(cameraPosition)
|
||||
const toCamera = cameraLocal.sub(new Vector3(p[0], p[1], p[2]))
|
||||
const nextSign = baseBranchAxis.dot(toCamera) >= 0 ? 1 : -1
|
||||
setSideSign((current) => (current === nextSign ? current : nextSign))
|
||||
})
|
||||
|
||||
const resizeHandleBase = extent + RESIZE_HANDLE_GAP
|
||||
const resizeHandles: {
|
||||
key: PipeDimension
|
||||
axis: Vector3
|
||||
cursor: Cursor
|
||||
guideFrom: Point
|
||||
guideTo: Point
|
||||
position: Point
|
||||
}[] = [
|
||||
{
|
||||
key: 'diameter',
|
||||
axis: runDiameterAxis,
|
||||
cursor: 'ns-resize',
|
||||
guideFrom: [
|
||||
p[0] + runDiameterAxis.x * resizeHandleBase,
|
||||
p[1] + runDiameterAxis.y * resizeHandleBase,
|
||||
p[2] + runDiameterAxis.z * resizeHandleBase,
|
||||
],
|
||||
guideTo: [
|
||||
p[0] + runDiameterAxis.x * Math.max(extent * 0.18, 0.04),
|
||||
p[1] + runDiameterAxis.y * Math.max(extent * 0.18, 0.04),
|
||||
p[2] + runDiameterAxis.z * Math.max(extent * 0.18, 0.04),
|
||||
],
|
||||
position: [
|
||||
p[0] + runDiameterAxis.x * resizeHandleBase,
|
||||
p[1] + runDiameterAxis.y * resizeHandleBase,
|
||||
p[2] + runDiameterAxis.z * resizeHandleBase,
|
||||
],
|
||||
},
|
||||
...(fitting.fittingType === 'elbow'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: 'diameter2' as const,
|
||||
axis: branchAxis,
|
||||
cursor: 'ew-resize' as Cursor,
|
||||
guideFrom: [
|
||||
p[0] + branchAxis.x * resizeHandleBase,
|
||||
p[1] + branchAxis.y * resizeHandleBase,
|
||||
p[2] + branchAxis.z * resizeHandleBase,
|
||||
] as Point,
|
||||
guideTo: [
|
||||
p[0] + branchAxis.x * Math.max(extent * 0.18, 0.04),
|
||||
p[1] + branchAxis.y * Math.max(extent * 0.18, 0.04),
|
||||
p[2] + branchAxis.z * Math.max(extent * 0.18, 0.04),
|
||||
] as Point,
|
||||
position: [
|
||||
p[0] + branchAxis.x * resizeHandleBase,
|
||||
p[1] + branchAxis.y * resizeHandleBase,
|
||||
p[2] + branchAxis.z * resizeHandleBase,
|
||||
] as Point,
|
||||
},
|
||||
]),
|
||||
]
|
||||
|
||||
const moveArrows: {
|
||||
key: string
|
||||
axis: RotationAxis
|
||||
position: Point
|
||||
rotationY: number
|
||||
vertical?: 'up' | 'down'
|
||||
cursor: Cursor
|
||||
}[] = [
|
||||
{ key: '+x', axis: 'x', position: [p[0] + base, p[1], p[2]], rotationY: 0, cursor: 'grab' },
|
||||
{
|
||||
key: '-x',
|
||||
axis: 'x',
|
||||
position: [p[0] - base, p[1], p[2]],
|
||||
rotationY: Math.PI,
|
||||
cursor: 'grab',
|
||||
},
|
||||
{
|
||||
key: '+z',
|
||||
axis: 'z',
|
||||
position: [p[0], p[1], p[2] + base],
|
||||
rotationY: -Math.PI / 2,
|
||||
cursor: 'grab',
|
||||
},
|
||||
{
|
||||
key: '-z',
|
||||
axis: 'z',
|
||||
position: [p[0], p[1], p[2] - base],
|
||||
rotationY: Math.PI / 2,
|
||||
cursor: 'grab',
|
||||
},
|
||||
{
|
||||
key: '+y',
|
||||
axis: 'y',
|
||||
position: [p[0], p[1] + base, p[2]],
|
||||
rotationY: 0,
|
||||
vertical: 'up',
|
||||
cursor: 'ns-resize',
|
||||
},
|
||||
{
|
||||
key: '-y',
|
||||
axis: 'y',
|
||||
position: [p[0], p[1] - base, p[2]],
|
||||
rotationY: 0,
|
||||
vertical: 'down',
|
||||
cursor: 'ns-resize',
|
||||
},
|
||||
]
|
||||
|
||||
const d = base * Math.SQRT1_2
|
||||
const rotateArcs: { key: string; axis: RotationAxis; position: Point; rotation: Point }[] = (
|
||||
['x', 'y', 'z'] as RotationAxis[]
|
||||
).map((axis) => {
|
||||
const q = new Quaternion().setFromUnitVectors(UP, AXIS_VECTORS[axis])
|
||||
if (axis === 'z') {
|
||||
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.z, Math.PI / 4))
|
||||
} else if (axis === 'x') {
|
||||
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.x, (-145 * Math.PI) / 180))
|
||||
} else if (axis === 'y') {
|
||||
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.y, (-45 * Math.PI) / 180))
|
||||
}
|
||||
const e = new Euler().setFromQuaternion(q)
|
||||
const position: Point =
|
||||
axis === 'x'
|
||||
? [p[0], p[1] + d, p[2] + d]
|
||||
: axis === 'y'
|
||||
? [p[0] + d, p[1], p[2] + d]
|
||||
: [p[0] + d, p[1] + d, p[2]]
|
||||
return { key: `r${axis}`, axis, position, rotation: [e.x, e.y, e.z] }
|
||||
})
|
||||
|
||||
if (dragging) return <group ref={setFrame} />
|
||||
return (
|
||||
<group ref={setFrame}>
|
||||
<HandleCube active={open} onClick={() => setOpen((o) => !o)} position={p} />
|
||||
{!open &&
|
||||
resizeHandles.map((handle) => (
|
||||
<group key={handle.key}>
|
||||
<DashedResizeGuide from={handle.guideFrom} to={handle.guideTo} />
|
||||
<ResizeSphereHandle
|
||||
cursor={handle.cursor}
|
||||
onPointerDown={beginDimensionDrag(handle.key, handle.axis, handle.cursor)}
|
||||
position={handle.position}
|
||||
/>
|
||||
</group>
|
||||
))}
|
||||
{open && (
|
||||
<>
|
||||
{moveArrows.map((a) => (
|
||||
<MoveChevron
|
||||
cursor={a.cursor}
|
||||
key={a.key}
|
||||
onPointerDown={beginDrag(
|
||||
a.axis === 'y' ? 'ns-resize' : 'grabbing',
|
||||
moveCompute(a.axis),
|
||||
)}
|
||||
position={a.position}
|
||||
rotationY={a.rotationY}
|
||||
vertical={a.vertical}
|
||||
/>
|
||||
))}
|
||||
{rotateArcs.map((arc) => (
|
||||
<RotateArc
|
||||
key={arc.key}
|
||||
onPointerDown={beginDrag('grabbing', rotateCompute(arc.axis))}
|
||||
position={arc.position}
|
||||
rotation={arc.rotation}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipeFittingSelectionAffordance
|
||||
|
||||
Reference in New Issue
Block a user