'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 { 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, ): Partial { return { ...fittingParameterPatch(node), ...data } as Partial } 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 } function ResizeSphereHandle({ cursor, onPointerDown, position, }: { cursor: Cursor onPointerDown: (event: ThreeEvent) => 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) => { event.stopPropagation() event.nativeEvent.stopPropagation() event.nativeEvent.stopImmediatePropagation() swallowNextClick() onPointerDown(event) } return ( { 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 = '' }} /> ) } /** * 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(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(, mount, undefined) } const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: Object3D }) => { const { camera, gl } = useThree() const [frame, setFrame] = useState(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 }[] => { if (!connectivity) return [] const preview = { ...(fitting as Record), ...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, ), } }) } /** * 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, ) => (event: PointerEvent) => FittingTransform | null, ) => (e: ThreeEvent) => { 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 }[] => [ { id: fitting.id as AnyNodeId, data: preserveFittingParameters(fitting, t as Partial), }, ...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 }[] = ( connectivity?.connections ?? [] ).map((conn) => { if (conn.kind !== 'rigid-node') { return { id: conn.nodeId, data: { path: conn.startPath } as Partial } } 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), } }) 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): ((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): ((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) => { e.stopPropagation() const baseValue = fitting[dimension] const initialPatch = { [dimension]: baseValue } as Partial 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 | null = null let lastValue = Number.NaN const apply = (patch: Partial) => { 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 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 } return ( setOpen((o) => !o)} position={p} /> {!open && resizeHandles.map((handle) => ( ))} {open && ( <> {moveArrows.map((a) => ( ))} {rotateArcs.map((arc) => ( ))} )} ) } export default DuctFittingSelectionAffordance