889 lines
31 KiB
TypeScript
889 lines
31 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
analyzePortConnectivity,
|
|
type Cursor,
|
|
type DuctFittingNode,
|
|
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 { 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 { INCHES_TO_METERS } from '../duct-segment/geometry'
|
|
import { autoOffsetInvalidationUpdates } from '../shared/auto-offset-tag'
|
|
import {
|
|
AXIS_VECTORS,
|
|
cycleRotationAxis,
|
|
ROTATE_STEP_RAD,
|
|
type RotationAxis,
|
|
} from '../shared/fitting-rotation'
|
|
import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles'
|
|
import { fittingLegLength } from './ports'
|
|
|
|
type Point = [number, number, number]
|
|
|
|
/** Stand-off (meters) from the fitting body to each arrow. */
|
|
const ARROW_GAP = 0.14
|
|
const RESIZE_HANDLE_GAP = 0.18
|
|
const RESIZE_STEP_IN = 1
|
|
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 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))
|
|
}
|
|
|
|
/** Rough body radius (meters) — the larger of the fitting's two collar reaches,
|
|
* used to stand the handles clear of the geometry. */
|
|
function fittingExtentM(node: DuctFittingNode): number {
|
|
const d2 = (node as { diameter2?: number }).diameter2 ?? node.diameter
|
|
return Math.max(fittingLegLength(node.diameter), fittingLegLength(d2))
|
|
}
|
|
|
|
/** The transform a drag frame writes onto the fitting. */
|
|
type FittingTransform = { position?: Point; rotation?: Point }
|
|
type FittingDimension = 'width' | 'height'
|
|
|
|
function fittingParameterPatch(node: DuctFittingNode): Partial<DuctFittingNode> {
|
|
return {
|
|
fittingType: node.fittingType,
|
|
shape: node.shape,
|
|
width: node.width,
|
|
height: node.height,
|
|
shape2: node.shape2,
|
|
width2: node.width2,
|
|
height2: node.height2,
|
|
angle: node.angle,
|
|
branchAngle: node.branchAngle,
|
|
diameter: node.diameter,
|
|
diameter2: node.diameter2,
|
|
ductMaterial: node.ductMaterial,
|
|
system: node.system,
|
|
}
|
|
}
|
|
|
|
function preserveFittingParameters(
|
|
node: DuctFittingNode,
|
|
data: Partial<DuctFittingNode>,
|
|
): Partial<AnyNode> {
|
|
return { ...fittingParameterPatch(node), ...data } as Partial<AnyNode>
|
|
}
|
|
|
|
function canResizeRunProfile(node: DuctFittingNode): boolean {
|
|
return (
|
|
node.fittingType === 'transition' || (node.fittingType !== 'reducer' && node.shape !== 'round')
|
|
)
|
|
}
|
|
|
|
function dimensionBounds(dimension: FittingDimension): { min: number; max: number } {
|
|
return dimension === 'width' ? { min: 4, max: 60 } : { min: 3, max: 40 }
|
|
}
|
|
|
|
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 affordances for a placed duct fitting — the 3D twin of the
|
|
* duct-segment selection rig. A CLICK-to-latch cube sits at the fitting center;
|
|
* clicking it opens (click again to close) a cluster of:
|
|
*
|
|
* - **Six move arrows** (±X / ±Y / ±Z): translate the whole fitting along one
|
|
* world axis. Connected runs follow via port connectivity.
|
|
* - **Three rotation arcs** (X / Y / Z): spin the fitting about each world
|
|
* axis. Connected runs re-aim via port follow.
|
|
* - **Two profile cubes** on the fitting's visible side/top faces: resize
|
|
* non-round fitting width and height without occupying the inside corner.
|
|
*
|
|
* The handle rig is PORTALED into the fitting group's PARENT — never the
|
|
* fitting group itself — because the selection outliner (`MergedOutlineNode`)
|
|
* traces every descendant mesh of the SELECTED node, so a hit-area cylinder
|
|
* parented under the fitting would be swept into its selection outline. Walls /
|
|
* doors / windows dodge it the same way. The fitting's local `position` is
|
|
* expressed in the parent's frame, so an identity group under the parent lets
|
|
* us place handles at absolute level-local coords with world-aligned axes.
|
|
*
|
|
* History does the single-undo dance: paused during the drag (live ticks are
|
|
* untracked), reverted on release, resumed, then the final transform re-applied
|
|
* as one tracked change so the whole joint is one undo step.
|
|
*/
|
|
const DuctFittingSelectionAffordance = () => {
|
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
const fitting = useScene((s) => {
|
|
if (selectedIds.length !== 1) return null
|
|
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
|
return node?.type === 'duct-fitting' ? (node as DuctFittingNode) : null
|
|
})
|
|
|
|
// Alt cycles the active rotation axis for the R / T keyboard rotate while a
|
|
// single fitting is selected (the gizmo's three arcs cover every axis on
|
|
// their own; this only keeps the keyboard action meaningful).
|
|
const hasSelectedFitting = !!fitting
|
|
useEffect(() => {
|
|
if (!hasSelectedFitting) return
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key !== 'Alt' || e.repeat) return
|
|
const tag = (e.target as HTMLElement | null)?.tagName
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
|
e.preventDefault()
|
|
cycleRotationAxis()
|
|
}
|
|
window.addEventListener('keydown', onKeyDown)
|
|
return () => window.removeEventListener('keydown', onKeyDown)
|
|
}, [hasSelectedFitting])
|
|
|
|
// Portal target: the fitting's registered group. Resolved with a rAF retry
|
|
// because registration lands on the renderer's mount, a frame after select.
|
|
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} target={target} />, mount, undefined)
|
|
}
|
|
|
|
const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: Object3D }) => {
|
|
const { camera, gl } = useThree()
|
|
const [frame, setFrame] = useState<Group | null>(null)
|
|
// True while the cluster is latched open. Click the center cube to toggle.
|
|
const [open, setOpen] = useState(false)
|
|
// True while a move / rotate drag is live — the arrows hide (the window
|
|
// pointer handlers own the gesture), exactly like the duct-segment rig.
|
|
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))
|
|
/** World hit on a vertical, camera-facing plane through `anchorWorld`,
|
|
* returned as a level-local Y (the frame is axis-aligned to the parent). */
|
|
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()
|
|
}
|
|
|
|
/** Cursor's coordinate on one world axis, in the frame's local space. For Y
|
|
* it rides a camera-facing vertical plane; for X / Z it projects onto the
|
|
* horizontal plane through the fitting and reads back the local component. */
|
|
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
|
|
}
|
|
|
|
// Follow-updates for runs / fittings mated to this fitting, given a preview
|
|
// transform. Endpoints whose ports didn't move resolve to a zero delta.
|
|
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 !== 'duct-fitting') return u
|
|
return {
|
|
id: u.id,
|
|
data: preserveFittingParameters(
|
|
node as DuctFittingNode,
|
|
u.data as Partial<DuctFittingNode>,
|
|
),
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Shared lifecycle for the move / rotate drags. `makeCompute` is built at
|
|
* pointer-down so it can capture the grab anchor (cursor's start coord /
|
|
* bearing) and avoid a teleport. Each frame `compute` turns the cursor into
|
|
* the fitting's next transform; the fitting writes it and any mated runs
|
|
* follow via port connectivity. Lands as one undo step.
|
|
*/
|
|
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<DuctFittingNode>),
|
|
},
|
|
...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 = () => {
|
|
// Swallow the trailing synthetic click so it doesn't reach the
|
|
// background-click deselect handler (cleanup drops `inputDragging`
|
|
// synchronously here).
|
|
swallowNextClick()
|
|
cleanup()
|
|
// Single-undo dance: revert the fitting AND its followers to the
|
|
// pre-drag state while history is still paused, resume, then re-apply
|
|
// the final transform as one tracked change.
|
|
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 === 'duct-fitting'
|
|
? preserveFittingParameters(node as DuctFittingNode, {
|
|
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) {
|
|
const scene = useScene.getState()
|
|
scene.updateNodes([
|
|
...buildBatch(current),
|
|
...autoOffsetInvalidationUpdates(scene.nodes, fitting.id as AnyNodeId),
|
|
])
|
|
}
|
|
}
|
|
|
|
window.addEventListener('pointermove', onMove)
|
|
window.addEventListener('pointerup', onUp)
|
|
window.addEventListener('pointercancel', onUp)
|
|
}
|
|
|
|
// Move: translate the fitting along one world axis, anchored to the cursor's
|
|
// start coord so it doesn't jump on grab. Y is clamped at the floor; Shift
|
|
// bypasses grid snapping.
|
|
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] = (
|
|
axis === 'y' ? Math.max(0, base[axisIndex] + delta) : base[axisIndex] + delta
|
|
) as number
|
|
return { position: next }
|
|
}
|
|
}
|
|
|
|
// Rotate: spin the fitting about one world axis. The cursor's bearing in the
|
|
// plane perpendicular to that axis (through the body center) drives the
|
|
// angle; world-frame premultiply so the axis means the screen X/Y/Z the user
|
|
// expects regardless of how the fitting is already turned.
|
|
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
|
|
// Snap the turn to 45° steps; Shift = smooth (no snap).
|
|
const raw = b - startBearing
|
|
const delta = event.shiftKey ? raw : Math.round(raw / ROTATE_STEP_RAD) * ROTATE_STEP_RAD
|
|
// Tick the rotate SFX each time a fresh snap step is crossed (snapped
|
|
// turns only — a smooth Shift-drag has no discrete steps to mark).
|
|
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: FittingDimension, axisLocal: Vector3, cursor: Cursor) =>
|
|
(e: ThreeEvent<PointerEvent>) => {
|
|
e.stopPropagation()
|
|
const baseValue = fitting[dimension]
|
|
const initialPatch = { [dimension]: baseValue } as Partial<DuctFittingNode>
|
|
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,
|
|
)
|
|
const { min, max } = dimensionBounds(dimension)
|
|
pauseSceneHistory(useScene)
|
|
useViewer.getState().setInputDragging(true)
|
|
setDragging(true)
|
|
document.body.style.cursor = cursor
|
|
let current: Partial<DuctFittingNode> | null = null
|
|
let lastValue = Number.NaN
|
|
|
|
const apply = (patch: Partial<DuctFittingNode>) => {
|
|
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), min, max)
|
|
if (nextValue === lastValue) return
|
|
lastValue = nextValue
|
|
current = { [dimension]: nextValue } as Partial<DuctFittingNode>
|
|
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 profileAxes = useMemo(() => {
|
|
const hingeAxis = new Vector3(0, 1, 0).applyEuler(fittingRotation).normalize()
|
|
const sideAxis = new Vector3(0, 0, 1).applyEuler(fittingRotation).normalize()
|
|
const hingeIsVertical = Math.abs(hingeAxis.y) >= Math.SQRT1_2
|
|
const hingeDimension: FittingDimension = hingeIsVertical ? 'height' : 'width'
|
|
const sideDimension: FittingDimension = hingeIsVertical ? 'width' : 'height'
|
|
const hingeEntry = { key: hingeDimension, axis: hingeAxis }
|
|
const sideEntry = { key: sideDimension, axis: sideAxis }
|
|
return Math.abs(hingeAxis.dot(UP)) >= Math.abs(sideAxis.dot(UP))
|
|
? { top: hingeEntry, side: sideEntry }
|
|
: { top: sideEntry, side: hingeEntry }
|
|
}, [fittingRotation])
|
|
const topAxis = useMemo(() => {
|
|
const axis = profileAxes.top.axis.clone()
|
|
return axis.dot(UP) >= 0 ? axis : axis.multiplyScalar(-1)
|
|
}, [profileAxes])
|
|
const baseSideAxis = profileAxes.side.axis
|
|
const sideAxis = useMemo(
|
|
() => baseSideAxis.clone().multiplyScalar(sideSign),
|
|
[baseSideAxis, sideSign],
|
|
)
|
|
useFrame(() => {
|
|
if (!frame) 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 = baseSideAxis.dot(toCamera) >= 0 ? 1 : -1
|
|
setSideSign((current) => (current === nextSign ? current : nextSign))
|
|
})
|
|
const resizeHandleBase = extent + RESIZE_HANDLE_GAP
|
|
const resizeHandles: {
|
|
key: FittingDimension
|
|
axis: Vector3
|
|
cursor: Cursor
|
|
guideFrom: Point
|
|
guideTo: Point
|
|
position: Point
|
|
}[] = canResizeRunProfile(fitting)
|
|
? [
|
|
{
|
|
key: profileAxes.top.key,
|
|
axis: topAxis,
|
|
cursor: 'ns-resize',
|
|
guideFrom: [
|
|
p[0] + topAxis.x * resizeHandleBase,
|
|
p[1] + topAxis.y * resizeHandleBase,
|
|
p[2] + topAxis.z * resizeHandleBase,
|
|
],
|
|
guideTo: [
|
|
p[0] + topAxis.x * Math.max(extent * 0.18, 0.04),
|
|
p[1] + topAxis.y * Math.max(extent * 0.18, 0.04),
|
|
p[2] + topAxis.z * Math.max(extent * 0.18, 0.04),
|
|
],
|
|
position: [
|
|
p[0] + topAxis.x * resizeHandleBase,
|
|
p[1] + topAxis.y * resizeHandleBase,
|
|
p[2] + topAxis.z * resizeHandleBase,
|
|
],
|
|
},
|
|
{
|
|
key: profileAxes.side.key,
|
|
axis: sideAxis,
|
|
cursor: 'ew-resize',
|
|
guideFrom: [
|
|
p[0] + sideAxis.x * resizeHandleBase,
|
|
p[1] + sideAxis.y * resizeHandleBase,
|
|
p[2] + sideAxis.z * resizeHandleBase,
|
|
],
|
|
guideTo: [
|
|
p[0] + sideAxis.x * Math.max(extent * 0.18, 0.04),
|
|
p[1] + sideAxis.y * Math.max(extent * 0.18, 0.04),
|
|
p[2] + sideAxis.z * Math.max(extent * 0.18, 0.04),
|
|
],
|
|
position: [
|
|
p[0] + sideAxis.x * resizeHandleBase,
|
|
p[1] + sideAxis.y * resizeHandleBase,
|
|
p[2] + sideAxis.z * resizeHandleBase,
|
|
],
|
|
},
|
|
]
|
|
: []
|
|
|
|
// Six whole-fitting move arrows, one per ± world axis.
|
|
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',
|
|
},
|
|
]
|
|
|
|
// Three rotation arcs, one per world axis. Each arc wraps its axis (the
|
|
// shared `curved-arrow` wraps world +Y by default; `setFromUnitVectors`
|
|
// re-aims it) and sits at a diagonal offset in the plane it spins, so the
|
|
// three don't pile onto the move arrows.
|
|
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])
|
|
// Spin the arc in place about its own axis so the grip sits where we want
|
|
// it without moving its position.
|
|
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 DuctFittingSelectionAffordance
|