368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AlignmentAnchor,
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
emitter,
|
|
type GridEvent,
|
|
PipeFittingNode,
|
|
sceneRegistry,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
DragBoundingBox,
|
|
EDITOR_LAYER,
|
|
isAlignmentGuideActive,
|
|
isMagneticSnapActive,
|
|
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). Guides are DISPLAYED in every
|
|
// snapping mode (isAlignmentGuideActive); the magnetic pull toward them
|
|
// applies only in 'lines' mode (isMagneticSnapActive).
|
|
if (!bypass && isAlignmentGuideActive()) {
|
|
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)
|
|
if (isMagneticSnapActive()) {
|
|
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
|