1628 lines
69 KiB
TypeScript
1628 lines
69 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
analyzePortConnectivity,
|
|
type Cursor,
|
|
type DuctFittingNode,
|
|
DuctSegmentNode,
|
|
nodeRegistry,
|
|
type PortConnectivity,
|
|
pauseSceneHistory,
|
|
resolveConnectivityUpdates,
|
|
resumeSceneHistory,
|
|
sceneRegistry,
|
|
useLiveNodeOverrides,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { DimensionPill, swallowNextClick, triggerSFX, useEditor } from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { Html } from '@react-three/drei'
|
|
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
Euler,
|
|
type Group,
|
|
Matrix4,
|
|
type Object3D,
|
|
Plane,
|
|
Quaternion,
|
|
Raycaster,
|
|
Vector2,
|
|
Vector3,
|
|
} from 'three'
|
|
import {
|
|
type AutoOffsetBasePatch,
|
|
newAutoOffsetGroupId,
|
|
readAutoOffsetTag,
|
|
translateAutoOffsetBase,
|
|
withAutoOffsetTag,
|
|
withoutAutoOffsetTag,
|
|
} from '../shared/auto-offset-tag'
|
|
import {
|
|
detectFittingEndpoint,
|
|
type FittingEndpoint,
|
|
planFittingEndpointReaim,
|
|
} from '../shared/fitting-endpoint-reaim'
|
|
import { DuctSegmentGhost, FittingGhost } from '../shared/mep-ghost'
|
|
import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports'
|
|
import { planRunTranslationOffsets } from '../shared/run-translation-offset'
|
|
import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles'
|
|
import { planVerticalOffsets, type VerticalOffsetResult } from '../shared/vertical-offset'
|
|
import { INCHES_TO_METERS } from './geometry'
|
|
|
|
/** Port-snap radius for dragged run endpoints (meters, XZ). */
|
|
const PORT_SNAP_RADIUS_M = 0.4
|
|
|
|
// In-world arrow handle layout (meters) — the arrows stand off the run body so
|
|
// they clear thick trunks.
|
|
const CORNER_ARROW_GAP = 0.18
|
|
const CORNER_ARROW_MIN_OFFSET = 0.24
|
|
|
|
/** Roll snap increment — 45°, matching the fitting rotate step. Shift bypasses. */
|
|
const ROLL_STEP_RAD = Math.PI / 4
|
|
|
|
const UP = new Vector3(0, 1, 0)
|
|
|
|
function collectPortsFromNodes(
|
|
nodes: Record<string, AnyNode>,
|
|
filter: { excludeNodeId?: AnyNodeId; systems?: readonly string[] } = {},
|
|
): ReturnType<typeof collectScenePorts> {
|
|
const { excludeNodeId, systems } = filter
|
|
const result: ReturnType<typeof collectScenePorts> = []
|
|
for (const node of Object.values(nodes)) {
|
|
if (!node || node.id === excludeNodeId) continue
|
|
const ports = nodeRegistry.get(node.type)?.ports?.(node)
|
|
if (!ports) continue
|
|
for (const port of ports) {
|
|
if (systems && port.system !== undefined && !systems.includes(port.system)) continue
|
|
result.push({ ...port, nodeId: node.id })
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function snap(value: number, step: number): number {
|
|
if (step <= 0) return value
|
|
return Math.round(value / step) * step
|
|
}
|
|
|
|
/** Half the run's cross-section (meters) — the arrow stand-off radius. */
|
|
function runRadiusM(duct: DuctSegmentNode): number {
|
|
if (duct.shape === 'round') return (duct.diameter * INCHES_TO_METERS) / 2
|
|
return (Math.max(duct.width, duct.height) * INCHES_TO_METERS) / 2
|
|
}
|
|
|
|
type Point = [number, number, number]
|
|
|
|
// What a corner arrow constrains its drag to: the vertical world axis, or a
|
|
// horizontal line along a run-relative direction (node-local XZ unit vector) —
|
|
// so a duct drawn at any angle keeps along-run / across-run arrows instead of
|
|
// world ±X / ±Z. `along` marks the pair that lies ON the run axis (lengthen /
|
|
// shorten) vs the across pair (swing). Most up / down arrows swing too, except
|
|
// pure vertical risers where the outward ±Y arrow IS the run axis.
|
|
type DragKind =
|
|
| { axis: 'y'; along?: boolean }
|
|
| { axis: 'horizontal'; dir: [number, number]; along: boolean }
|
|
|
|
// What a run-center arrow translates the WHOLE run along: the vertical world
|
|
// axis, or a horizontal line down a run-relative direction (node-local XZ unit
|
|
// vector) — so the along-/across-run arrows align with the run instead of
|
|
// world ±X / ±Z. Unlike a corner drag there's no pivot, so no swing: every
|
|
// path point shifts by the same delta.
|
|
type RunMoveKind = { axis: 'y' } | { axis: 'horizontal'; dir: [number, number] }
|
|
|
|
type CornerArrow = {
|
|
key: string
|
|
/** Path point this arrow drives. */
|
|
index: number
|
|
kind: DragKind
|
|
/** World-local arrow position (offset off the point along its direction). */
|
|
position: Point
|
|
rotationY: number
|
|
/** Set for the vertical pair — tips the flat chevron up / down. */
|
|
vertical?: 'up' | 'down'
|
|
cursor: Cursor
|
|
}
|
|
|
|
/**
|
|
* Selection-time editing for committed duct runs: each path point shows a
|
|
* cluster of directional arrows instead of a free-drag handle — four XZ
|
|
* chevrons (±X / ±Z) plus an up / down vertical pair.
|
|
*
|
|
* Handles are PORTALED into the duct's registered scene group so they
|
|
* share its exact frame — path coords are node-local, and the level /
|
|
* building transform above the group applies to the handles for free.
|
|
* Drag raycasts run in world space and convert hits back into the
|
|
* group's local frame before writing the path.
|
|
*
|
|
* Drag model: the along-run arrow lengthens / shortens the run (locked to the
|
|
* run axis). The across-run (side) and up / down arrows instead SWING the
|
|
* grabbed endpoint around its neighbour at a fixed radius — the run pivots like
|
|
* a compass arm, keeping its length, rather than stretching. Dragged run
|
|
* endpoints still snap onto nearby typed ports (along-run drag) so a loose run
|
|
* can be mated onto a fitting after the fact, and when the dragged endpoint
|
|
* belongs to a straight run whose OTHER end sits on an elbow collar, the elbow
|
|
* re-aims to follow the drag (junction + far collar fixed, bend angle adapts).
|
|
*
|
|
* Modifiers (mirroring the wall corner drag):
|
|
* - **Alt** detaches: the joint breaks for this drag — the elbow does NOT
|
|
* re-aim and mated fittings / runs do NOT follow; the endpoint moves on its
|
|
* own (port re-mate still allowed so it can be reattached elsewhere).
|
|
* - **Shift** bypasses grid snapping for a perfectly smooth precision drag.
|
|
*
|
|
* History does the single-undo dance: paused during the drag (the live
|
|
* `updateNode` ticks are untracked), then on release the path is
|
|
* reverted, history resumed, and the final path applied as one tracked
|
|
* change.
|
|
*/
|
|
const DuctSegmentSelectionAffordance = () => {
|
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
const duct = useScene((s) => {
|
|
if (selectedIds.length !== 1) return null
|
|
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
|
return node?.type === 'duct-segment' ? (node as DuctSegmentNode) : null
|
|
})
|
|
|
|
// Portal target: the duct's registered group. Resolved with a rAF
|
|
// retry because registration happens on the renderer's mount, which
|
|
// can land a frame after selection.
|
|
const ductId = duct?.id ?? null
|
|
const [target, setTarget] = useState<Object3D | null>(null)
|
|
useEffect(() => {
|
|
if (!ductId) {
|
|
setTarget(null)
|
|
return
|
|
}
|
|
let frameId = 0
|
|
const resolve = () => {
|
|
const next = sceneRegistry.nodes.get(ductId as AnyNodeId) ?? null
|
|
setTarget((cur) => (cur === next ? cur : next))
|
|
if (!next) frameId = window.requestAnimationFrame(resolve)
|
|
}
|
|
resolve()
|
|
return () => window.cancelAnimationFrame(frameId)
|
|
}, [ductId])
|
|
|
|
if (!duct || !target) return null
|
|
// Mount the handles in the duct group's PARENT (a sibling of the duct
|
|
// mesh), NOT inside the duct group itself. The selection outliner
|
|
// (`MergedOutlineNode`) traces every descendant mesh of the SELECTED node,
|
|
// so a hit-area cylinder parented under the duct would get swept into the
|
|
// duct's selection outline — the stray circle around the arrows. Walls /
|
|
// doors / windows dodge this the same way: their handle rig rides the
|
|
// parent, never the selected node. `DuctPointHandles` re-creates the duct
|
|
// group's own pose on an outer group so placements stay node-local.
|
|
const mount = target.parent ?? target
|
|
return createPortal(<DuctPointHandles duct={duct} target={target} />, mount, undefined)
|
|
}
|
|
|
|
const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Object3D }) => {
|
|
const { camera, gl } = useThree()
|
|
// Outer group mirrors the duct group's local pose so handles placed in
|
|
// node-local path coords land exactly where the duct mesh sits, even though
|
|
// they're mounted in the parent (to stay out of the duct's selection
|
|
// outline). Mirrors the wall arrow rig's ride-group.
|
|
const outerRef = useRef<Group>(null)
|
|
useFrame(() => {
|
|
const outer = outerRef.current
|
|
if (!outer) return
|
|
outer.position.copy(target.position)
|
|
outer.quaternion.copy(target.quaternion)
|
|
outer.scale.copy(target.scale)
|
|
})
|
|
const unit = useViewer((s) => s.unit)
|
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
|
|
const [rolling, setRolling] = useState(false)
|
|
const [runMoving, setRunMoving] = useState(false)
|
|
// Auto-routed offset preview shown while the run-center cube's ±Y arrows
|
|
// lift / lower a connected run. Two flavours:
|
|
// - `valid` (green): the elbows + plumb riser the release will mint are
|
|
// ghosted while the run lifts + trims live; release commits them.
|
|
// - `invalid` (red): no clean offset fits at this height. ONLY the dragged
|
|
// run lifts (as a red preview); every partner is frozen and the release
|
|
// snaps back, committing nothing. The fittings/risers arrays are empty
|
|
// here — there's nothing buildable to ghost, the run body carries the cue.
|
|
// Null when the move is a plain translate (open ends, or no connections).
|
|
const [verticalGhost, setVerticalGhost] = useState<{
|
|
tint: 'valid' | 'invalid'
|
|
fittings: DuctFittingNode[]
|
|
risers: DuctSegmentNode[]
|
|
} | null>(null)
|
|
// Which cube's arrow cluster is open. Hover proved too fiddly (the cursor has
|
|
// to bridge the gap between the dot and its offset arrows), so the cubes are
|
|
// CLICK-to-latch instead: clicking a cube opens its cluster and closes any
|
|
// other; clicking the same cube again closes it. A vertex cluster is keyed by
|
|
// its index, the run-center cluster by 'center'. Cleared on deselect (the
|
|
// whole rig unmounts) and after a drag commits.
|
|
type OpenCluster = number | 'center' | null
|
|
const [openCluster, setOpenCluster] = useState<OpenCluster>(null)
|
|
const toggleCluster = (key: Exclude<OpenCluster, null>) =>
|
|
setOpenCluster((cur) => (cur === key ? null : key))
|
|
// Set while a drag is live; null otherwise. Holds everything the window
|
|
// pointer handlers need so they never read stale React state.
|
|
const dragRef = useRef<{
|
|
index: number
|
|
initialPath: Point[]
|
|
current: Point
|
|
cleanup: () => void
|
|
// Connectivity snapshot taken at pointer-down: which fittings / ducts are
|
|
// mated to this run's endpoints, so they follow as the endpoint moves.
|
|
connectivity: PortConnectivity | null
|
|
// Set when the run's OTHER end sits on a re-aimable fitting collar (an
|
|
// elbow inlet/outlet, or a tee branch): the fitting re-aims to follow this
|
|
// drag instead of translating rigidly (mutually exclusive with
|
|
// `connectivity`-driven follow for this endpoint).
|
|
fittingEndpoint: FittingEndpoint | null
|
|
// True while Alt is held: the joint is detached for this drag, so the
|
|
// final commit must omit elbow / connectivity updates. Tracked live so
|
|
// `onUp` knows what the last frame did.
|
|
detached: boolean
|
|
} | null>(null)
|
|
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Local-frame Y where the cursor ray meets a vertical plane through
|
|
* `anchorWorld` that faces the camera — drives the up / down vertical drag.
|
|
* Null when the ray is parallel to the plane.
|
|
*/
|
|
const intersectVerticalY = (
|
|
clientX: number,
|
|
clientY: number,
|
|
anchorWorld: Vector3,
|
|
): number | null => {
|
|
// Plane normal: camera forward flattened onto the horizontal plane, so
|
|
// the plane stands upright through the point and faces the viewer.
|
|
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 ? toLocal(hit)[1] : null
|
|
}
|
|
|
|
/**
|
|
* Length-preserving HORIZONTAL swing: the unit direction from `pivot` toward
|
|
* a point sharing the cursor's heading but keeping the run's current pitch
|
|
* (vertical component). Caller re-extends it to the fixed radius. Sweeping
|
|
* the end around the pivot in the horizontal plane (a yaw) without changing
|
|
* length. Null when the cursor sits on the pivot's vertical axis (no
|
|
* heading) or the ray misses.
|
|
*/
|
|
const swingHorizontal = (event: PointerEvent, pivot: Point, startPoint: Point): Point | null => {
|
|
const r = Math.hypot(
|
|
startPoint[0] - pivot[0],
|
|
startPoint[1] - pivot[1],
|
|
startPoint[2] - pivot[2],
|
|
)
|
|
if (r < 1e-6) return null
|
|
const verticalN = (startPoint[1] - pivot[1]) / r
|
|
const horizN = Math.sqrt(Math.max(0, 1 - verticalN * verticalN))
|
|
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(pivot))
|
|
const hit = intersect(event.clientX, event.clientY, plane)
|
|
if (!hit) return null
|
|
const local = toLocal(hit)
|
|
const bx = local[0] - pivot[0]
|
|
const bz = local[2] - pivot[2]
|
|
const blen = Math.hypot(bx, bz)
|
|
if (blen < 1e-6) return null
|
|
return [(bx / blen) * horizN, verticalN, (bz / blen) * horizN]
|
|
}
|
|
|
|
/**
|
|
* Length-preserving VERTICAL swing: the unit direction from `pivot` toward
|
|
* the cursor within the vertical plane that contains the run's current
|
|
* horizontal heading — so the end tilts up / down (changing pitch) while its
|
|
* heading and length stay fixed. Falls back to a camera-facing vertical plane
|
|
* for a pure riser (no horizontal heading). Null when the ray misses.
|
|
*/
|
|
const swingVertical = (event: PointerEvent, pivot: Point, startPoint: Point): Point | null => {
|
|
let hx = startPoint[0] - pivot[0]
|
|
let hz = startPoint[2] - pivot[2]
|
|
let hlen = Math.hypot(hx, hz)
|
|
if (hlen < 1e-6) {
|
|
// Pure riser: no heading — sweep in the plane facing the camera.
|
|
const forward = camera.getWorldDirection(new Vector3())
|
|
hx = forward.x
|
|
hz = forward.z
|
|
hlen = Math.hypot(hx, hz)
|
|
if (hlen < 1e-6) {
|
|
hx = 0
|
|
hz = 1
|
|
hlen = 1
|
|
}
|
|
}
|
|
const headingWorld = new Vector3(hx / hlen, 0, hz / hlen)
|
|
// Plane normal: horizontal, perpendicular to the heading — the plane stands
|
|
// upright and contains both the heading and world-up through the pivot.
|
|
const normal = new Vector3().crossVectors(UP, headingWorld).normalize()
|
|
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, toWorld(pivot))
|
|
const hit = intersect(event.clientX, event.clientY, plane)
|
|
if (!hit) return null
|
|
const local = toLocal(hit)
|
|
const ax = local[0] - pivot[0]
|
|
const ay = local[1] - pivot[1]
|
|
const az = local[2] - pivot[2]
|
|
const len = Math.hypot(ax, ay, az)
|
|
if (len < 1e-6) return null
|
|
return [ax / len, ay / len, az / len]
|
|
}
|
|
|
|
// Build the per-frame update batch for the dragged endpoint at `next`.
|
|
// Detached (Alt): only the duct path moves — no fitting re-aim, no
|
|
// connectivity follow. Re-aim mode: the run rides the fitting's re-aimed
|
|
// collar and the fitting swings to fit (elbow body turns, or a tee branch
|
|
// leans). Otherwise: the dragged point moves and any mated fittings / runs
|
|
// translate via connectivity.
|
|
const buildDragBatch = (
|
|
drag: NonNullable<typeof dragRef.current>,
|
|
next: Point,
|
|
detached: boolean,
|
|
): { id: AnyNodeId; data: Partial<AnyNode> }[] | null => {
|
|
if (!detached && drag.fittingEndpoint) {
|
|
const plan = planFittingEndpointReaim(drag.fittingEndpoint, drag.index, next)
|
|
// Out of the fitting's buildable range — hold this frame.
|
|
if (!plan) return null
|
|
return [
|
|
{ id: duct.id as AnyNodeId, data: { path: plan.path } },
|
|
{ id: plan.fittingUpdate.id, data: plan.fittingUpdate.data },
|
|
]
|
|
}
|
|
const path = duct.path.map((p, i) => (i === drag.index ? next : p)) as Point[]
|
|
return [
|
|
{ id: duct.id as AnyNodeId, data: { path } },
|
|
...(detached ? [] : connectivityUpdatesForPath(drag.connectivity, path)),
|
|
]
|
|
}
|
|
|
|
/** World-space position of a local path point. */
|
|
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
|
|
/** Convert a world-space hit back into the duct group's local frame. */
|
|
const toLocal = (world: Vector3): Point => {
|
|
const local = target.worldToLocal(world.clone())
|
|
return [local.x, local.y, local.z]
|
|
}
|
|
|
|
// Follow-updates for fittings / ducts mated to this run's endpoints, given
|
|
// the run's live path. Endpoints whose position didn't change resolve to a
|
|
// zero delta, so only the dragged endpoint's partner actually moves.
|
|
const connectivityUpdatesForPath = (
|
|
connectivity: PortConnectivity | null,
|
|
path: Point[],
|
|
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
|
if (!connectivity) return []
|
|
const preview = { ...(duct as Record<string, unknown>), path } as AnyNode
|
|
return resolveConnectivityUpdates(connectivity, preview).filter(
|
|
(u) => useScene.getState().nodes[u.id],
|
|
)
|
|
}
|
|
|
|
// A corner arrow drag: the point is locked to one axis (X / Z / Y). All the
|
|
// rich behaviour — port-snap, elbow re-aim, connectivity follow, single-undo
|
|
// — is shared with every arrow; only the
|
|
// cursor→point projection differs per `kind`.
|
|
const onHandleDown = (index: number, kind: DragKind) => (e: ThreeEvent<PointerEvent>) => {
|
|
e.stopPropagation()
|
|
const initialPath = duct.path.map((p) => [...p] as Point)
|
|
const startPoint = initialPath[index]!
|
|
const connectivity = analyzePortConnectivity(duct as AnyNode, useScene.getState().nodes)
|
|
pauseSceneHistory(useScene)
|
|
useViewer.getState().setInputDragging(true)
|
|
document.body.style.cursor = kind.axis === 'y' ? 'ns-resize' : 'grabbing'
|
|
setDraggingIndex(index)
|
|
|
|
const isEndpoint = index === 0 || index === initialPath.length - 1
|
|
|
|
// Swing pivot: the across-run (side) and up / down arrows DON'T stretch the
|
|
// run — they sweep the grabbed point around its neighbour at a fixed radius
|
|
// (the segment's current length), so the run pivots like a compass arm
|
|
// instead of lengthening. The along-run pair keeps the plain lengthen /
|
|
// shorten. The pivot is the adjacent vertex; null when there's no neighbour
|
|
// (a lone point) or the grabbed segment has zero length.
|
|
const swings = kind.axis === 'y' ? kind.along !== true : !kind.along
|
|
// Pivot only at an endpoint (its single neighbour is the unambiguous "other
|
|
// end"); interior vertices keep the plain per-axis drag.
|
|
const neighborIndex = index === 0 ? 1 : index === initialPath.length - 1 ? index - 1 : null
|
|
const pivot = neighborIndex !== null ? initialPath[neighborIndex]! : null
|
|
const radius = pivot
|
|
? Math.hypot(startPoint[0] - pivot[0], startPoint[1] - pivot[1], startPoint[2] - pivot[2])
|
|
: 0
|
|
const canSwing = swings && isEndpoint && pivot !== null && radius > 1e-6
|
|
|
|
// Fitting re-aim: if this is a straight run whose OTHER end sits on an
|
|
// elbow collar (junction + far collar fixed, bend angle adapts) or a tee
|
|
// branch collar (run legs fixed, branch lean adapts), the fitting swings
|
|
// to follow the drag. Detected once against a drag-start snapshot.
|
|
const fittingEndpoint: FittingEndpoint | null = isEndpoint
|
|
? detectFittingEndpoint('duct-segment', initialPath, index, useScene.getState().nodes)
|
|
: null
|
|
|
|
const onMove = (event: PointerEvent) => {
|
|
const drag = dragRef.current
|
|
if (!drag) return
|
|
// Shift = precision: bypass grid snapping (snap() is a no-op at step 0).
|
|
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
|
// Alt = detach: break the joint for this drag (it can still port-snap to
|
|
// re-mate elsewhere). Mirrors the wall corner drag.
|
|
const detached = event.altKey
|
|
let next: Point | null = null
|
|
if (canSwing && pivot) {
|
|
// Length-preserving swing: aim from the pivot toward the cursor and
|
|
// re-extend to the fixed radius. The up / down arrow swings in the
|
|
// vertical plane that contains the run (so it tilts the run up / down
|
|
// without changing its length); the side arrow swings in the
|
|
// horizontal plane (a yaw about the pivot).
|
|
const aim =
|
|
kind.axis === 'y'
|
|
? swingVertical(event, pivot, startPoint)
|
|
: swingHorizontal(event, pivot, startPoint)
|
|
if (aim) {
|
|
// The swung endpoint follows the grid snap points by default; Shift
|
|
// sets step 0 so it sweeps smoothly. Snapping the landed coords (not
|
|
// the arc angle) keeps the endpoint on the grid like every other
|
|
// arrow, trading a hair of the fixed radius for grid alignment.
|
|
next = [
|
|
snap(pivot[0] + aim[0] * radius, step),
|
|
Math.max(0, snap(pivot[1] + aim[1] * radius, step)),
|
|
snap(pivot[2] + aim[2] * radius, step),
|
|
]
|
|
}
|
|
} else if (kind.axis === 'y') {
|
|
// Vertical (riser): keep XZ pinned to the start and drive Y off the
|
|
// cursor against a vertical plane through the point.
|
|
const y = intersectVerticalY(event.clientX, event.clientY, toWorld(startPoint))
|
|
if (y !== null) next = [startPoint[0], Math.max(0, snap(y, step)), startPoint[2]]
|
|
} else {
|
|
// Horizontal: project the cursor onto the plane at the point's height,
|
|
// then lock to the arrow's run-relative line (node-local XZ direction)
|
|
// through the point — so an angled run drags along / across itself, not
|
|
// world ±X / ±Z.
|
|
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(startPoint))
|
|
const hit = intersect(event.clientX, event.clientY, plane)
|
|
if (hit) {
|
|
const local = toLocal(hit)
|
|
const [dx, dz] = kind.dir
|
|
// Signed displacement of the cursor along the lock direction, snapped.
|
|
const t = snap((local[0] - startPoint[0]) * dx + (local[2] - startPoint[2]) * dz, step)
|
|
next = [startPoint[0] + t * dx, startPoint[1], startPoint[2] + t * dz]
|
|
}
|
|
}
|
|
if (!next) return
|
|
// Port re-mate for any endpoint arrow — the along-/across-run drags AND
|
|
// the length-preserving swings all snap onto a nearby typed port so a
|
|
// loose run can be mated onto a fitting after the fact (the swing's fixed
|
|
// radius yields to the port). Stays available while detaching or
|
|
// free-dragging; suppressed only while a fitting is actively re-aiming.
|
|
if (isEndpoint && (detached || !drag.fittingEndpoint)) {
|
|
const port = findNearestPortXZ(
|
|
[next[0], next[1], next[2]],
|
|
collectScenePorts({ excludeNodeId: duct.id, systems: DUCT_PORT_SYSTEMS }),
|
|
PORT_SNAP_RADIUS_M,
|
|
)
|
|
if (port) next = [port.position[0], port.position[1], port.position[2]]
|
|
}
|
|
if (next[0] === drag.current[0] && next[1] === drag.current[1] && next[2] === drag.current[2])
|
|
return
|
|
const batch = buildDragBatch(drag, next, detached)
|
|
if (!batch) return
|
|
drag.current = next
|
|
drag.detached = detached
|
|
// Tick on each new snapped position — the same grid-snap SFX the draw
|
|
// tools fire; the player debounces rapid repeats (minIntervalMs). Only
|
|
// when the grid is live (step > 0): Shift-precision has nothing to snap.
|
|
if (step > 0) triggerSFX('sfx:grid-snap')
|
|
useScene.getState().updateNodes(batch)
|
|
}
|
|
|
|
const onUp = () => {
|
|
const drag = dragRef.current
|
|
if (!drag) return
|
|
// Swallow the trailing synthetic click so it doesn't reach the
|
|
// background-click deselect handler — `cleanup()` drops `inputDragging`
|
|
// synchronously here, so without this the click that fires after
|
|
// pointerup would land with the drag gate already down and clear the
|
|
// selection. Mirrors `useHandleDrag`'s onUp.
|
|
swallowNextClick()
|
|
drag.cleanup()
|
|
dragRef.current = null
|
|
setDraggingIndex(null)
|
|
// Single-undo dance: revert (still paused), resume, re-apply the final
|
|
// batch as one tracked change. The final batch is built the same way as
|
|
// each live frame (elbow re-aim, rigid connectivity follow, or — when
|
|
// detached — just the duct path).
|
|
const detached = drag.detached
|
|
const finalBatch = buildDragBatch(drag, drag.current, detached)
|
|
// Revert the run AND whatever the drag carried to their pre-drag state
|
|
// while paused so history captures a clean before→after delta. When
|
|
// detached nothing else moved, so only the run needs reverting.
|
|
const revertUpdates: { id: AnyNodeId; data: Partial<AnyNode> }[] = detached
|
|
? []
|
|
: drag.fittingEndpoint
|
|
? [drag.fittingEndpoint.revert]
|
|
: (drag.connectivity?.connections ?? []).map((conn) =>
|
|
conn.kind === 'rigid-node'
|
|
? { id: conn.nodeId, data: { position: conn.startPosition } as Partial<AnyNode> }
|
|
: { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> },
|
|
)
|
|
useScene
|
|
.getState()
|
|
.updateNodes([
|
|
{ id: duct.id as AnyNodeId, data: { path: drag.initialPath } },
|
|
...revertUpdates.filter((u) => useScene.getState().nodes[u.id]),
|
|
])
|
|
resumeSceneHistory(useScene)
|
|
const moved = drag.current.some((v, axis) => v !== drag.initialPath[drag.index]![axis])
|
|
if (moved && finalBatch) {
|
|
// A manual corner edit invalidates any stored auto-offset base (the
|
|
// tag's snapshot no longer matches the geometry), so strip the tag on
|
|
// commit. The duct becomes plain independent geometry.
|
|
const finalWithTagStrip = finalBatch.map((u) =>
|
|
u.id === (duct.id as AnyNodeId) && readAutoOffsetTag(duct)
|
|
? {
|
|
...u,
|
|
data: {
|
|
...u.data,
|
|
metadata: withoutAutoOffsetTag(duct.metadata),
|
|
} as Partial<AnyNode>,
|
|
}
|
|
: u,
|
|
)
|
|
useScene.getState().updateNodes(finalWithTagStrip)
|
|
}
|
|
}
|
|
|
|
const cleanup = () => {
|
|
window.removeEventListener('pointermove', onMove)
|
|
window.removeEventListener('pointerup', onUp)
|
|
window.removeEventListener('pointercancel', onUp)
|
|
useViewer.getState().setInputDragging(false)
|
|
document.body.style.cursor = ''
|
|
}
|
|
|
|
dragRef.current = {
|
|
index,
|
|
initialPath,
|
|
current: startPoint,
|
|
cleanup,
|
|
connectivity,
|
|
fittingEndpoint,
|
|
detached: false,
|
|
}
|
|
window.addEventListener('pointermove', onMove)
|
|
window.addEventListener('pointerup', onUp)
|
|
window.addEventListener('pointercancel', onUp)
|
|
}
|
|
|
|
// Roll: spin the rect / oval cross-section about the run (length) axis. The
|
|
// cursor's bearing in the plane perpendicular to the run direction (taken in
|
|
// the cross-section's own width / height basis so the angle maps 1:1 to the
|
|
// visible profile) drives the `roll` field. Round runs look identical at any
|
|
// roll, so the gizmo isn't rendered for them. Roll doesn't move the ports
|
|
// (they sit on the path, whose positions are unchanged), so mated runs /
|
|
// fittings need no follow — just the single-undo dance on the scalar field.
|
|
const onRollDown = (e: ThreeEvent<PointerEvent>) => {
|
|
e.stopPropagation()
|
|
const axis = runAxisAndCenter(duct)
|
|
if (!axis) return
|
|
const startRoll = duct.roll
|
|
const dir = new Vector3(axis.dir[0], axis.dir[1], axis.dir[2])
|
|
const worldDir = target.localToWorld(dir.clone()).sub(target.localToWorld(new Vector3()))
|
|
worldDir.normalize()
|
|
const center = toWorld(axis.center)
|
|
// Width / height axes at roll 0, mapped to world — the bearing basis. This
|
|
// is the same construction `rectSectionAxes` uses, so a turn of the cursor
|
|
// by θ rolls the section by θ.
|
|
const xBase = new Vector3().crossVectors(UP, dir)
|
|
if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0)
|
|
xBase.normalize()
|
|
const zBase = new Vector3().crossVectors(xBase, dir).normalize()
|
|
const u = target.localToWorld(xBase.clone()).sub(target.localToWorld(new Vector3())).normalize()
|
|
const v = target.localToWorld(zBase.clone()).sub(target.localToWorld(new Vector3())).normalize()
|
|
const plane = new Plane().setFromNormalAndCoplanarPoint(worldDir, 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)
|
|
pauseSceneHistory(useScene)
|
|
useViewer.getState().setInputDragging(true)
|
|
document.body.style.cursor = 'grabbing'
|
|
setRolling(true)
|
|
let current = startRoll
|
|
let lastStep = Number.NaN
|
|
|
|
const onMove = (event: PointerEvent) => {
|
|
if (startBearing === null) return
|
|
const b = bearing(event.clientX, event.clientY)
|
|
if (b === null) return
|
|
// Snap the roll to 45° steps; Shift = smooth (no snap).
|
|
const raw = b - startBearing
|
|
const delta = event.shiftKey ? raw : Math.round(raw / ROLL_STEP_RAD) * ROLL_STEP_RAD
|
|
const next = startRoll + delta
|
|
if (next === current) return
|
|
current = next
|
|
// Tick the rotate SFX each time a fresh snap step is crossed (snapped
|
|
// rolls only — a smooth Shift-drag has no discrete steps to mark).
|
|
if (!event.shiftKey) {
|
|
const step = Math.round(raw / ROLL_STEP_RAD)
|
|
if (step !== lastStep) {
|
|
lastStep = step
|
|
triggerSFX('sfx:item-rotate')
|
|
}
|
|
}
|
|
useScene.getState().updateNode(duct.id, { roll: next })
|
|
}
|
|
|
|
const onUp = () => {
|
|
swallowNextClick()
|
|
window.removeEventListener('pointermove', onMove)
|
|
window.removeEventListener('pointerup', onUp)
|
|
window.removeEventListener('pointercancel', onUp)
|
|
useViewer.getState().setInputDragging(false)
|
|
document.body.style.cursor = ''
|
|
setRolling(false)
|
|
// Single-undo dance: revert to the pre-drag roll while paused, resume,
|
|
// then re-apply the final roll as one tracked change. A roll edit also
|
|
// invalidates any stored auto-offset base, so strip the tag on commit.
|
|
useScene.getState().updateNode(duct.id, { roll: startRoll })
|
|
resumeSceneHistory(useScene)
|
|
if (current !== startRoll) {
|
|
useScene.getState().updateNode(duct.id, {
|
|
roll: current,
|
|
...(readAutoOffsetTag(duct) ? { metadata: withoutAutoOffsetTag(duct.metadata) } : {}),
|
|
} as Partial<AnyNode>)
|
|
}
|
|
}
|
|
|
|
window.addEventListener('pointermove', onMove)
|
|
window.addEventListener('pointerup', onUp)
|
|
window.addEventListener('pointercancel', onUp)
|
|
}
|
|
|
|
// Move the WHOLE run, locked to one direction: the vertical world axis, or a
|
|
// run-relative horizontal line (node-local XZ unit dir) so the along-/across-
|
|
// run arrows track the run instead of world ±X / ±Z. Every path point shifts
|
|
// by the same delta; mated fittings / runs follow via connectivity, and the
|
|
// gesture lands as a single undo step (the same dance the per-point drag
|
|
// uses). The center arrows each bind one direction; the projection is the
|
|
// only thing that differs.
|
|
const onRunMoveDown = (kind: RunMoveKind) => (e: ThreeEvent<PointerEvent>) => {
|
|
e.stopPropagation()
|
|
const parentId = (duct.parentId ?? undefined) as AnyNodeId | undefined
|
|
const initialPath = duct.path.map((p) => [...p] as Point)
|
|
const center = runAxisAndCenter(duct)?.center ?? initialPath[0]!
|
|
const anchorWorld = toWorld(center)
|
|
// Cross-section the auto-routed offset fittings inherit (vertical move).
|
|
const profile = {
|
|
shape: duct.shape,
|
|
diameter: duct.diameter,
|
|
width: duct.width,
|
|
height: duct.height,
|
|
}
|
|
|
|
// Auto-routed offsets carry an auto-offset tag recording the minted
|
|
// elbows/risers + the pre-offset base. A re-drag must REWIND that tag
|
|
// (delete the prior minted nodes, restore the base) before planning the
|
|
// fresh offset — otherwise each re-drag strands the previous elbows/riser
|
|
// in place. Read the tag on vertical drags so the rewind/delete plumbing
|
|
// (keyed on `mintedIds` below) runs; horizontal moves translate a stored
|
|
// base if one remains.
|
|
const isVertical = kind.axis === 'y'
|
|
const preRewindNodes = useScene.getState().nodes
|
|
const existingTag = readAutoOffsetTag(duct)
|
|
const tag = isVertical ? existingTag : null
|
|
const runBase = tag?.base.find((b) => b.id === (duct.id as AnyNodeId))
|
|
const logicalPath = ((runBase?.data as { path?: Point[] } | undefined)?.path?.map(
|
|
(p) => [...p] as Point,
|
|
) ?? initialPath) as Point[]
|
|
const baseDy = tag?.dy ?? 0
|
|
const mintedIds = (tag?.minted ?? []) as AnyNodeId[]
|
|
// Snapshots of the existing minted nodes, so the pre-commit revert can
|
|
// restore the original Z (the single-undo baseline) before the final write.
|
|
const mintedSnapshots = mintedIds
|
|
.map((id) => preRewindNodes[id])
|
|
.filter((n): n is AnyNode => Boolean(n))
|
|
const previewDeletedSnapshots = new Map<AnyNodeId, AnyNode>()
|
|
|
|
pauseSceneHistory(useScene)
|
|
|
|
const ensureSceneObjectsVisible = (ids: Iterable<AnyNodeId>) => {
|
|
const scene = useScene.getState()
|
|
for (const id of ids) {
|
|
const obj = sceneRegistry.nodes.get(id)
|
|
if (obj) {
|
|
obj.visible = true
|
|
obj.traverse((child) => {
|
|
child.visible = true
|
|
})
|
|
}
|
|
if (scene.nodes[id]) scene.markDirty(id)
|
|
}
|
|
}
|
|
const livePreviewIds = new Set<AnyNodeId>()
|
|
const publishLivePreview = (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => {
|
|
const scene = useScene.getState()
|
|
const entries = updates
|
|
.filter((update) => scene.nodes[update.id])
|
|
.map((update) => [update.id, update.data as Record<string, unknown>] as const)
|
|
if (entries.length === 0) return
|
|
useLiveNodeOverrides.getState().setMany(entries)
|
|
for (const [id] of entries) {
|
|
livePreviewIds.add(id)
|
|
scene.markDirty(id)
|
|
}
|
|
}
|
|
const clearLivePreview = () => {
|
|
const overrides = useLiveNodeOverrides.getState()
|
|
for (const id of livePreviewIds) overrides.clear(id)
|
|
livePreviewIds.clear()
|
|
}
|
|
|
|
// Connectivity + ports are read from an IN-MEMORY post-rewind scene (the
|
|
// logical L), so click-only pointerdown doesn't visually snap the committed
|
|
// offset down to the floor. The real scene is only rewound after the first
|
|
// actual drag delta, inside `applyLogicalBasePreview`.
|
|
const nodesById: Record<string, AnyNode> = { ...preRewindNodes }
|
|
if (tag) {
|
|
for (const id of mintedIds) delete nodesById[id]
|
|
for (const b of tag.base) {
|
|
const current = nodesById[b.id]
|
|
if (current) nodesById[b.id] = { ...current, ...b.data } as AnyNode
|
|
}
|
|
}
|
|
const logicalDuct = DuctSegmentNode.parse({
|
|
...duct,
|
|
path: logicalPath,
|
|
metadata: withoutAutoOffsetTag(duct.metadata),
|
|
})
|
|
nodesById[duct.id as AnyNodeId] = logicalDuct as AnyNode
|
|
const connectivity = analyzePortConnectivity(logicalDuct as AnyNode, nodesById)
|
|
const scenePorts = collectPortsFromNodes(nodesById, {
|
|
excludeNodeId: duct.id as AnyNodeId,
|
|
systems: DUCT_PORT_SYSTEMS,
|
|
})
|
|
|
|
// Grab offset: cursor's start coordinate along the locked direction (signed
|
|
// distance for the horizontal run-relative line), so the run doesn't jump
|
|
// on grab.
|
|
const sample = (clientX: number, clientY: number): number | null => {
|
|
if (kind.axis === 'y') return intersectVerticalY(clientX, clientY, anchorWorld)
|
|
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, anchorWorld)
|
|
const hit = intersect(clientX, clientY, plane)
|
|
if (!hit) return null
|
|
const local = toLocal(hit)
|
|
return local[0] * kind.dir[0] + local[2] * kind.dir[1]
|
|
}
|
|
const startSample = sample(e.nativeEvent.clientX, e.nativeEvent.clientY)
|
|
useViewer.getState().setInputDragging(true)
|
|
document.body.style.cursor = kind.axis === 'y' ? 'ns-resize' : 'grabbing'
|
|
setRunMoving(true)
|
|
let delta = 0
|
|
// Last frame's offset outcome (vertical move only): a valid plan to mint on
|
|
// release, an invalid marker (preview-only, snap back), or null for a plain
|
|
// translate. `onUp` reads it to decide create vs. translate vs. nothing.
|
|
let offsetResult: VerticalOffsetResult = null
|
|
|
|
// Shift the LOGICAL L: vertical adds the carried `baseDy` plus this drag's
|
|
// delta (so a re-drag accumulates from the L, not the lifted Z); horizontal
|
|
// (always untagged → baseDy 0, logicalPath === initialPath) slides in plane.
|
|
const shiftedPath = (d: number): Point[] =>
|
|
logicalPath.map((p) => {
|
|
const next = [...p] as Point
|
|
if (kind.axis === 'y') {
|
|
next[1] = Math.max(0, p[1] + baseDy + d)
|
|
} else {
|
|
next[0] = p[0] + d * kind.dir[0]
|
|
next[2] = p[2] + d * kind.dir[1]
|
|
}
|
|
return next
|
|
})
|
|
const batchFor = (path: Point[]): { id: AnyNodeId; data: Partial<AnyNode> }[] => [
|
|
{ id: duct.id as AnyNodeId, data: { path } },
|
|
...connectivityUpdatesForPath(connectivity, path),
|
|
]
|
|
|
|
// Patches restoring every connected partner to its post-rewind (logical-L)
|
|
// pose — runs by path, fittings by position (+ a re-aimed elbow's rotation /
|
|
// angle). Used to FREEZE partners on an invalid frame.
|
|
const partnerReverts = (): { 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 start = nodesById[conn.nodeId] as Record<string, unknown> | undefined
|
|
const data: Record<string, unknown> = { position: conn.startPosition }
|
|
if (start?.rotation !== undefined) data.rotation = start.rotation
|
|
if (start?.angle !== undefined) data.angle = start.angle
|
|
return { id: conn.nodeId, data: data as Partial<AnyNode> }
|
|
})
|
|
.filter((u) => useScene.getState().nodes[u.id])
|
|
|
|
// Base for a NEWLY formed offset (untagged run): the logical L IS the
|
|
// drag-start pose, so the L is `initialPath` and partners are at their
|
|
// start paths / poses. A re-drag carries the existing tag's base forward.
|
|
const freshBase = (): AutoOffsetBasePatch[] => [
|
|
{ id: duct.id as AnyNodeId, data: { path: initialPath } },
|
|
...partnerReverts().map((u) => ({ id: u.id, data: u.data as Record<string, unknown> })),
|
|
]
|
|
|
|
// Patches restoring partners to their PRE-rewind (original Z) poses — the
|
|
// single-undo baseline. Untagged: same as the L (no rewind happened).
|
|
const originalPartnerReverts = (): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
|
if (!tag) return partnerReverts()
|
|
return tag.base
|
|
.filter((b) => b.id !== (duct.id as AnyNodeId))
|
|
.map((b) => {
|
|
const orig = preRewindNodes[b.id] as Record<string, unknown> | undefined
|
|
if (!orig) return null
|
|
if ('path' in orig) return { id: b.id, data: { path: orig.path } as Partial<AnyNode> }
|
|
const data: Record<string, unknown> = {}
|
|
if (orig.position !== undefined) data.position = orig.position
|
|
if (orig.rotation !== undefined) data.rotation = orig.rotation
|
|
if (orig.angle !== undefined) data.angle = orig.angle
|
|
return { id: b.id, data: data as Partial<AnyNode> }
|
|
})
|
|
.filter((u): u is { id: AnyNodeId; data: Partial<AnyNode> } => u !== null)
|
|
.filter((u) => useScene.getState().nodes[u.id])
|
|
}
|
|
|
|
const restoreOriginalOffsetPreview = () => {
|
|
const scene = useScene.getState()
|
|
const partnerUpdates = originalPartnerReverts()
|
|
const updates = [
|
|
{
|
|
id: duct.id as AnyNodeId,
|
|
data: { path: duct.path, metadata: duct.metadata } as Partial<AnyNode>,
|
|
},
|
|
...partnerUpdates,
|
|
]
|
|
publishLivePreview(updates)
|
|
scene.applyNodeChanges({
|
|
create: mintedSnapshots
|
|
.filter((n) => !scene.nodes[n.id])
|
|
.map((node) => ({ node, parentId })),
|
|
update: updates,
|
|
})
|
|
ensureSceneObjectsVisible([
|
|
duct.id as AnyNodeId,
|
|
...mintedSnapshots.map((node) => node.id as AnyNodeId),
|
|
...partnerUpdates.map((update) => update.id),
|
|
])
|
|
}
|
|
|
|
const restorePreviewDeleted = (keepDeleted: readonly AnyNodeId[] = []) => {
|
|
const keep = new Set<AnyNodeId>(keepDeleted)
|
|
const scene = useScene.getState()
|
|
const create: { node: AnyNode; parentId?: AnyNodeId }[] = []
|
|
for (const [id, node] of previewDeletedSnapshots) {
|
|
if (keep.has(id)) continue
|
|
if (!scene.nodes[id]) {
|
|
create.push({
|
|
node,
|
|
parentId: (node.parentId ?? undefined) as AnyNodeId | undefined,
|
|
})
|
|
}
|
|
previewDeletedSnapshots.delete(id)
|
|
}
|
|
if (create.length > 0) {
|
|
scene.applyNodeChanges({ create })
|
|
ensureSceneObjectsVisible(create.map(({ node }) => node.id as AnyNodeId))
|
|
}
|
|
}
|
|
|
|
const applyLogicalBasePreview = () => {
|
|
if (!tag) return
|
|
const scene = useScene.getState()
|
|
const updates = [
|
|
{
|
|
id: duct.id as AnyNodeId,
|
|
data: {
|
|
path: logicalPath,
|
|
metadata: withoutAutoOffsetTag(duct.metadata),
|
|
} as Partial<AnyNode>,
|
|
},
|
|
...tag.base
|
|
.filter((b) => b.id !== (duct.id as AnyNodeId) && scene.nodes[b.id])
|
|
.map((b) => ({ id: b.id, data: b.data as Partial<AnyNode> })),
|
|
]
|
|
publishLivePreview(updates)
|
|
scene.applyNodeChanges({
|
|
delete: mintedIds.filter((id) => scene.nodes[id]),
|
|
update: updates,
|
|
})
|
|
ensureSceneObjectsVisible(updates.map((update) => update.id))
|
|
}
|
|
|
|
const onMove = (event: PointerEvent) => {
|
|
if (startSample === null) return
|
|
const s = sample(event.clientX, event.clientY)
|
|
if (s === null) return
|
|
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
|
const next = snap(s - startSample, step)
|
|
if (next === delta) return
|
|
delta = next
|
|
if (step > 0) triggerSFX('sfx:grid-snap')
|
|
const dyEff = baseDy + next
|
|
// Vertical move: connected ends stay welded to their (stationary)
|
|
// partner via an auto-routed elbow → riser → elbow offset, planned from
|
|
// the LOGICAL L by the accumulated lift. The planner returns valid (offset
|
|
// fits), invalid (connected but no clean offset at this height), or null
|
|
// (no connections, or dyEff 0 → back at the L → plain translate).
|
|
offsetResult =
|
|
isVertical && dyEff !== 0
|
|
? planVerticalOffsets({
|
|
duct: logicalDuct,
|
|
dy: dyEff,
|
|
profile,
|
|
connections: connectivity?.connections ?? [],
|
|
scenePorts,
|
|
nodesById,
|
|
})
|
|
: null
|
|
if (offsetResult?.status === 'valid') {
|
|
applyLogicalBasePreview()
|
|
// Lift the run to the offset path; trim run-offset partners back one
|
|
// leg, and let connectivity-follow (seeded from followPath, which zeroes
|
|
// the offset ends) lift any FITTING / open partner so its elbow rides
|
|
// up and its riser lengthens into a clean L. Ghost the new elbows +
|
|
// riser GREEN; they're minted for real on release.
|
|
const plan = offsetResult.plan
|
|
const scene = useScene.getState()
|
|
const deletePreview = (plan.delete ?? []).filter((id) => scene.nodes[id])
|
|
for (const id of deletePreview) {
|
|
const node = scene.nodes[id]
|
|
if (node) previewDeletedSnapshots.set(id, node)
|
|
}
|
|
restorePreviewDeleted(plan.delete ?? [])
|
|
const followUpdates = connectivityUpdatesForPath(connectivity, plan.followPath)
|
|
const updates = [
|
|
{ id: duct.id as AnyNodeId, data: { path: plan.ductPath } },
|
|
...plan.updates.filter((u) => scene.nodes[u.id]),
|
|
...followUpdates,
|
|
]
|
|
publishLivePreview(updates)
|
|
scene.applyNodeChanges({
|
|
delete: deletePreview,
|
|
update: updates,
|
|
})
|
|
ensureSceneObjectsVisible(updates.map((update) => update.id))
|
|
setVerticalGhost({ tint: 'valid', fittings: plan.fittings, risers: plan.risers })
|
|
} else if (offsetResult?.status === 'invalid') {
|
|
// No clean offset at this height. Keep the last committed network
|
|
// visible (including any prior auto-offset we rewound at drag-start)
|
|
// and show a RED ghost of the run where it WOULD lift to. Nothing is
|
|
// committed on release, so the run snaps back to its prior state.
|
|
restorePreviewDeleted()
|
|
restoreOriginalOffsetPreview()
|
|
const lifted = DuctSegmentNode.parse({ ...logicalDuct, path: shiftedPath(next) })
|
|
setVerticalGhost({ tint: 'invalid', fittings: [], risers: [lifted] })
|
|
} else {
|
|
restorePreviewDeleted()
|
|
applyLogicalBasePreview()
|
|
setVerticalGhost(null)
|
|
const updates = batchFor(shiftedPath(next))
|
|
publishLivePreview(updates)
|
|
useScene.getState().updateNodes(updates)
|
|
ensureSceneObjectsVisible(updates.map((update) => update.id))
|
|
}
|
|
}
|
|
|
|
const onUp = () => {
|
|
swallowNextClick()
|
|
window.removeEventListener('pointermove', onMove)
|
|
window.removeEventListener('pointerup', onUp)
|
|
window.removeEventListener('pointercancel', onUp)
|
|
useViewer.getState().setInputDragging(false)
|
|
document.body.style.cursor = ''
|
|
setRunMoving(false)
|
|
setVerticalGhost(null)
|
|
clearLivePreview()
|
|
// Single-undo dance: while paused, revert to the PRE-drag baseline (the
|
|
// original Z — recreate the minted nodes the rewind deleted and restore
|
|
// the run + partners), resume, then write the final state as ONE tracked
|
|
// change so undo jumps straight back to the original.
|
|
const restore = useScene.getState()
|
|
const restoredPreviewNodes = Array.from(previewDeletedSnapshots.values()).filter(
|
|
(node) => !restore.nodes[node.id],
|
|
)
|
|
const restoredMintedNodes = mintedSnapshots.filter((n) => !restore.nodes[n.id])
|
|
const restoreUpdates = [
|
|
{
|
|
id: duct.id as AnyNodeId,
|
|
data: { path: duct.path, metadata: duct.metadata } as Partial<AnyNode>,
|
|
},
|
|
...originalPartnerReverts(),
|
|
]
|
|
restore.applyNodeChanges({
|
|
create: [
|
|
...restoredMintedNodes.map((node) => ({ node, parentId })),
|
|
...restoredPreviewNodes.map((node) => ({
|
|
node,
|
|
parentId: (node.parentId ?? undefined) as AnyNodeId | undefined,
|
|
})),
|
|
],
|
|
update: restoreUpdates,
|
|
})
|
|
ensureSceneObjectsVisible([
|
|
...restoredMintedNodes.map((node) => node.id as AnyNodeId),
|
|
...restoredPreviewNodes.map((node) => node.id as AnyNodeId),
|
|
...restoreUpdates.map((update) => update.id),
|
|
])
|
|
resumeSceneHistory(useScene)
|
|
if (delta === 0) return
|
|
const dyEff = baseDy + delta
|
|
const result = offsetResult
|
|
// Invalid lift: nothing buildable at this height — keep the prior state
|
|
// (the revert above already restored it) and commit nothing.
|
|
if (result?.status === 'invalid') return
|
|
const scene = useScene.getState()
|
|
if (result?.status === 'valid') {
|
|
// Mint the new offset (fresh elbows + riser), delete any prior minted,
|
|
// trim run-offset partners, lift fitting / open partners via
|
|
// connectivity-follow, and stamp the run with the offset tag — all one
|
|
// tracked change. The base (logical L) is carried forward unchanged.
|
|
const plan = result.plan
|
|
const group = tag?.group ?? newAutoOffsetGroupId()
|
|
const base = tag?.base ?? freshBase()
|
|
const minted = [...plan.fittings, ...plan.risers]
|
|
const hasMintedOffset = minted.length > 0
|
|
const newTag = {
|
|
group,
|
|
dy: plan.dy,
|
|
minted: minted.map((n) => n.id as AnyNodeId),
|
|
base,
|
|
}
|
|
const followUpdates = connectivityUpdatesForPath(connectivity, plan.followPath)
|
|
const updates = [
|
|
{
|
|
id: duct.id as AnyNodeId,
|
|
data: {
|
|
path: plan.ductPath,
|
|
metadata: hasMintedOffset
|
|
? withAutoOffsetTag(duct.metadata, newTag)
|
|
: withoutAutoOffsetTag(duct.metadata),
|
|
} as Partial<AnyNode>,
|
|
},
|
|
...plan.updates.filter((u) => scene.nodes[u.id]),
|
|
...followUpdates,
|
|
]
|
|
scene.applyNodeChanges({
|
|
create: minted.map((node) => ({ node, parentId })),
|
|
delete: [
|
|
...mintedIds.filter((id) => scene.nodes[id]),
|
|
...(plan.delete ?? []).filter((id) => scene.nodes[id]),
|
|
],
|
|
update: updates,
|
|
})
|
|
ensureSceneObjectsVisible([
|
|
...minted.map((node) => node.id as AnyNodeId),
|
|
...updates.map((update) => update.id),
|
|
])
|
|
} else if (tag && dyEff === 0) {
|
|
// Dragged a re-grabbed offset all the way back to its logical L: the Z
|
|
// dissolves for good — delete the minted nodes, drop the tag, and leave
|
|
// the run + partners on the clean L.
|
|
const updates = [
|
|
{
|
|
id: duct.id as AnyNodeId,
|
|
data: {
|
|
path: logicalPath,
|
|
metadata: withoutAutoOffsetTag(duct.metadata),
|
|
} as Partial<AnyNode>,
|
|
},
|
|
...tag.base
|
|
.filter((b) => b.id !== (duct.id as AnyNodeId) && scene.nodes[b.id])
|
|
.map((b) => ({ id: b.id, data: b.data as Partial<AnyNode> })),
|
|
]
|
|
scene.applyNodeChanges({
|
|
delete: mintedIds.filter((id) => scene.nodes[id]),
|
|
update: updates,
|
|
})
|
|
ensureSceneObjectsVisible(updates.map((update) => update.id))
|
|
} else {
|
|
// Plain translate, or a horizontal connected slide that can be
|
|
// rerouted with elbows + a connector instead of dragging the network.
|
|
const finalPath = shiftedPath(delta)
|
|
const translationPlan =
|
|
!isVertical && connectivity
|
|
? planRunTranslationOffsets({
|
|
duct: logicalDuct,
|
|
translatedPath: finalPath,
|
|
profile,
|
|
connections: connectivity.connections,
|
|
scenePorts,
|
|
nodesById,
|
|
})
|
|
: null
|
|
if (translationPlan) {
|
|
const created = [...translationPlan.fittings, ...translationPlan.connectors]
|
|
const updates = [
|
|
{ id: duct.id as AnyNodeId, data: { path: translationPlan.ductPath } },
|
|
...translationPlan.updates,
|
|
]
|
|
scene.applyNodeChanges({
|
|
create: created.map((node) => ({
|
|
node: node as AnyNode,
|
|
parentId,
|
|
})),
|
|
update: updates,
|
|
})
|
|
ensureSceneObjectsVisible([
|
|
...created.map((node) => node.id as AnyNodeId),
|
|
...updates.map((update) => update.id),
|
|
])
|
|
return
|
|
}
|
|
const updates = batchFor(finalPath)
|
|
if (isVertical && existingTag) {
|
|
updates[0] = {
|
|
...updates[0]!,
|
|
data: {
|
|
...updates[0]!.data,
|
|
metadata: withoutAutoOffsetTag(duct.metadata),
|
|
} as Partial<AnyNode>,
|
|
}
|
|
}
|
|
if (!isVertical && existingTag && kind.axis === 'horizontal') {
|
|
const movedTag = translateAutoOffsetBase(existingTag, [
|
|
delta * kind.dir[0],
|
|
0,
|
|
delta * kind.dir[1],
|
|
])
|
|
updates[0] = {
|
|
...updates[0]!,
|
|
data: {
|
|
...updates[0]!.data,
|
|
metadata: withAutoOffsetTag(duct.metadata, movedTag),
|
|
} as Partial<AnyNode>,
|
|
}
|
|
}
|
|
useScene.getState().updateNodes(updates)
|
|
ensureSceneObjectsVisible(updates.map((update) => update.id))
|
|
}
|
|
}
|
|
|
|
window.addEventListener('pointermove', onMove)
|
|
window.addEventListener('pointerup', onUp)
|
|
window.addEventListener('pointercancel', onUp)
|
|
}
|
|
|
|
const cornerArrows = useMemo(() => getCornerArrows(duct), [duct])
|
|
const rollGizmo = useMemo(() => (duct.shape === 'round' ? null : runAxisAndCenter(duct)), [duct])
|
|
// Run-center cube position (centroid centerline). The six whole-run move
|
|
// arrows + the roll arc are revealed on hover, like the per-vertex clusters.
|
|
const runCenter = useMemo<Point | null>(() => runAxisAndCenter(duct)?.center ?? null, [duct])
|
|
// Yaw the center cube to the run's horizontal heading so it stays aligned
|
|
// with the run (matching the per-vertex cubes). A pure riser has no heading.
|
|
const runCenterYaw = useMemo<number>(() => {
|
|
const axis = runAxisAndCenter(duct)
|
|
if (!axis || Math.hypot(axis.dir[0], axis.dir[2]) < 1e-6) return 0
|
|
return Math.atan2(-axis.dir[2], axis.dir[0])
|
|
}, [duct])
|
|
// Six whole-run move arrows offset off the center: four horizontal (along-run
|
|
// ± and across-run ±, aligned to the run's XZ tangent so they track the run
|
|
// instead of world ±X / ±Z) plus the up / down vertical pair. All shift every
|
|
// path point rigidly — no swing (the whole run has no pivot).
|
|
const centerArrows = useMemo(() => {
|
|
if (!runCenter) return []
|
|
const base = Math.max(runRadiusM(duct) + CORNER_ARROW_GAP, CORNER_ARROW_MIN_OFFSET)
|
|
// Run's horizontal heading (node-local XZ). A pure riser has none → fall
|
|
// back to world +X so the arrows stay usable.
|
|
const axis = runAxisAndCenter(duct)
|
|
const t: [number, number] =
|
|
axis && Math.hypot(axis.dir[0], axis.dir[2]) > 1e-6
|
|
? (() => {
|
|
const len = Math.hypot(axis.dir[0], axis.dir[2])
|
|
return [axis.dir[0] / len, axis.dir[2] / len]
|
|
})()
|
|
: [1, 0]
|
|
const runYaw = Math.atan2(-t[1], t[0])
|
|
const horiz: { key: string; dir: [number, number] }[] = [
|
|
{ key: 'along+', dir: [t[0], t[1]] },
|
|
{ key: 'along-', dir: [-t[0], -t[1]] },
|
|
{ key: 'across+', dir: [-t[1], t[0]] },
|
|
{ key: 'across-', dir: [t[1], -t[0]] },
|
|
]
|
|
const arrows: {
|
|
key: string
|
|
kind: RunMoveKind
|
|
position: Point
|
|
rotationY: number
|
|
vertical?: 'up' | 'down'
|
|
cursor: Cursor
|
|
}[] = horiz.map(({ key, dir }) => ({
|
|
key,
|
|
kind: { axis: 'horizontal', dir },
|
|
position: [runCenter[0] + dir[0] * base, runCenter[1], runCenter[2] + dir[1] * base],
|
|
rotationY: Math.atan2(-dir[1], dir[0]),
|
|
cursor: 'grab',
|
|
}))
|
|
arrows.push(
|
|
{
|
|
key: '+y',
|
|
kind: { axis: 'y' },
|
|
position: [runCenter[0], runCenter[1] + base, runCenter[2]],
|
|
rotationY: runYaw,
|
|
vertical: 'up',
|
|
cursor: 'ns-resize',
|
|
},
|
|
{
|
|
key: '-y',
|
|
kind: { axis: 'y' },
|
|
position: [runCenter[0], runCenter[1] - base, runCenter[2]],
|
|
rotationY: runYaw,
|
|
vertical: 'down',
|
|
cursor: 'ns-resize',
|
|
},
|
|
)
|
|
return arrows
|
|
}, [duct, runCenter])
|
|
|
|
return (
|
|
<group ref={outerRef}>
|
|
{/* Auto-routed offset preview (vertical run-center move). Valid (green):
|
|
the elbows + plumb riser the release will mint, ghosted while the real
|
|
run lifts live. Invalid (red): a single ghost of the run at the
|
|
attempted height — the real network is frozen and release snaps back. */}
|
|
{verticalGhost?.fittings.map((f) => (
|
|
<FittingGhost fitting={f} key={`vghost-fit-${f.id}`} tint={verticalGhost.tint} />
|
|
))}
|
|
{verticalGhost?.risers.map((r) => (
|
|
<DuctSegmentGhost duct={r} key={`vghost-riser-${r.id}`} tint={verticalGhost.tint} />
|
|
))}
|
|
{/* Per-vertex affordances — hidden while a drag / roll is live (the window
|
|
pointer handlers own the gesture). Each vertex shows a small cube;
|
|
CLICKING the cube latches its directional cluster open (click again to
|
|
close), so a multi-bend run isn't a thicket of darts and the reveal
|
|
doesn't depend on a finicky hover. */}
|
|
{draggingIndex === null &&
|
|
!rolling &&
|
|
!runMoving &&
|
|
duct.path.map((p, i) => (
|
|
<group key={`vtx${i}`}>
|
|
<HandleCube
|
|
active={openCluster === i}
|
|
onClick={() => toggleCluster(i)}
|
|
position={p as Point}
|
|
rotationY={vertexYaw(duct, i)}
|
|
/>
|
|
{openCluster === i &&
|
|
cornerArrows
|
|
.filter((a) => a.index === i)
|
|
.map((a) => (
|
|
<MoveChevron
|
|
cursor={a.cursor}
|
|
key={a.key}
|
|
onPointerDown={onHandleDown(a.index, a.kind)}
|
|
position={a.position}
|
|
rotationY={a.rotationY}
|
|
vertical={a.vertical}
|
|
/>
|
|
))}
|
|
</group>
|
|
))}
|
|
{/* Run-center cube — clicking it latches the whole-run cluster open: six
|
|
axis-locked move arrows (±X / ±Y / ±Z) plus the roll arc (rect / oval
|
|
only). The six arrows shift every path point by the same delta; the
|
|
roll arc spins the cross-section about the run axis. */}
|
|
{draggingIndex === null && !rolling && !runMoving && runCenter && (
|
|
<group>
|
|
<HandleCube
|
|
active={openCluster === 'center'}
|
|
onClick={() => toggleCluster('center')}
|
|
position={runCenter}
|
|
rotationY={runCenterYaw}
|
|
/>
|
|
{openCluster === 'center' && (
|
|
<>
|
|
{centerArrows.map((a) => (
|
|
<MoveChevron
|
|
cursor={a.cursor}
|
|
key={a.key}
|
|
onPointerDown={onRunMoveDown(a.kind)}
|
|
position={a.position}
|
|
rotationY={a.rotationY}
|
|
vertical={a.vertical}
|
|
/>
|
|
))}
|
|
{rollGizmo && (
|
|
<RollHandle
|
|
center={rollGizmo.center}
|
|
dir={rollGizmo.dir}
|
|
onPointerDown={onRollDown}
|
|
radius={runRadiusM(duct) + CORNER_ARROW_GAP}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</group>
|
|
)}
|
|
{draggingIndex !== null &&
|
|
duct.path[draggingIndex] &&
|
|
(() => {
|
|
// Same pill as the draw tool: signed per-axis deltas from the
|
|
// drag-start position, dominant axis emphasised.
|
|
const point = duct.path[draggingIndex]!
|
|
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
|
|
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
|
|
const axes = ['x', 'y', 'z'] as const
|
|
const primary = axes.reduce((best, axis, i) =>
|
|
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
|
|
)
|
|
return (
|
|
<Html
|
|
center
|
|
position={[point[0], point[1] + 0.35, point[2]]}
|
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
zIndexRange={[100, 0]}
|
|
>
|
|
<DimensionPill
|
|
parts={axes.map((axis, i) => ({
|
|
key: axis,
|
|
prefix: axis.toUpperCase(),
|
|
value: deltas[i]!,
|
|
signed: true,
|
|
}))}
|
|
primary={primary}
|
|
unit={unit}
|
|
/>
|
|
</Html>
|
|
)
|
|
})()}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Roll gizmo — the shared `RotateArc` re-oriented to wrap the run's length
|
|
* axis, seated at a FIXED corner of the section frame. It does NOT track
|
|
* `roll`, so the grip stays still while the user spins the duct (otherwise it
|
|
* chases the cursor and is hard to keep hold of).
|
|
*
|
|
* The `curved-arrow` geometry wraps its LOCAL +Y (the spin axis) and bulges
|
|
* its arc apex along LOCAL +X. Orienting with a single `setFromUnitVectors`
|
|
* only pins the spin axis and leaves the apex pointing an arbitrary way, so the
|
|
* arc faced inconsistently across run directions. Instead we build a FULLY
|
|
* determined basis: +Y → run direction (the roll axis), +X → the top-outer 45°
|
|
* corner direction (so the apex always bulges outward at that corner), +Z the
|
|
* derived right-handed third axis. Position rides the same corner direction off
|
|
* the centre, so the grip and its curve always agree.
|
|
*/
|
|
function RollHandle({
|
|
center,
|
|
dir,
|
|
radius,
|
|
onPointerDown,
|
|
}: {
|
|
center: Point
|
|
dir: Point
|
|
radius: number
|
|
onPointerDown: (e: ThreeEvent<PointerEvent>) => void
|
|
}) {
|
|
const { position, rotation } = useMemo<{
|
|
position: Point
|
|
rotation: [number, number, number]
|
|
}>(() => {
|
|
const runDir = new Vector3(dir[0], dir[1], dir[2]).normalize()
|
|
const xBase = new Vector3().crossVectors(UP, runDir)
|
|
if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0)
|
|
xBase.normalize()
|
|
const zBase = new Vector3().crossVectors(xBase, runDir).normalize()
|
|
const a = Math.PI / 4
|
|
// Top-outer 45° corner of the section frame (between the top and a side
|
|
// face) — both the position offset and the arc apex ride this direction.
|
|
const cornerDir = xBase
|
|
.clone()
|
|
.multiplyScalar(Math.sin(a))
|
|
.addScaledVector(zBase, -Math.cos(a))
|
|
.normalize()
|
|
const pos: Point = [
|
|
center[0] + cornerDir.x * radius,
|
|
center[1] + cornerDir.y * radius,
|
|
center[2] + cornerDir.z * radius,
|
|
]
|
|
// Basis: X = apex/corner, Y = roll axis (run dir), Z = right-handed third.
|
|
const zAxis = new Vector3().crossVectors(cornerDir, runDir).normalize()
|
|
const q = new Quaternion().setFromRotationMatrix(
|
|
new Matrix4().makeBasis(cornerDir, runDir, zAxis),
|
|
)
|
|
const e = new Euler().setFromQuaternion(q)
|
|
return { position: pos, rotation: [e.x, e.y, e.z] }
|
|
}, [center, dir, radius])
|
|
|
|
return <RotateArc onPointerDown={onPointerDown} position={position} rotation={rotation} />
|
|
}
|
|
|
|
// Per-point directional arrows: at every path vertex, four horizontal chevrons
|
|
// (along-run ± and across-run ±) plus an up / down vertical pair. The
|
|
// horizontal pair is aligned to the run's node-local TANGENT at that vertex,
|
|
// not world ±X / ±Z, so a duct drawn at any angle (or rotated) keeps
|
|
// along-/across-run arrows. Pure vertical risers have no XZ tangent, so their
|
|
// horizontal arrows become side-swing controls and the outward ±Y chevron
|
|
// becomes the along-run length handle. `rotationY` orients each flat chevron to
|
|
// point along its direction (the same `atan2(-z, x)` mapping the wall move
|
|
// handles use). Arrows stand off the run body so they clear thick trunks. At an
|
|
// endpoint the chevron pointing back INTO the run (toward the neighbour) is
|
|
// dropped — its outward twin already shortens / lengthens that end either way.
|
|
function getCornerArrows(duct: DuctSegmentNode): CornerArrow[] {
|
|
const arrows: CornerArrow[] = []
|
|
const base = Math.max(runRadiusM(duct) + CORNER_ARROW_GAP, CORNER_ARROW_MIN_OFFSET)
|
|
const last = duct.path.length - 1
|
|
duct.path.forEach((p, i) => {
|
|
// Run tangent at this vertex (node-local XZ). A pure-riser vertex has no
|
|
// horizontal extent, so keep its side arrows world-cardinal and let the
|
|
// outward vertical arrow carry the along-run action.
|
|
const tangentXZ = vertexTangentXZ(duct, i)
|
|
const verticalTangentY = tangentXZ ? null : vertexTangentY(duct, i)
|
|
const t = tangentXZ ?? ([1, 0] as [number, number])
|
|
// Yaw that aligns a handle's local +X with the run tangent — shared by the
|
|
// up / down chevrons so their flat plates align with the run (the ±Y tip
|
|
// is added on top, and yawing about Y keeps them pointing up / down).
|
|
const runYaw = Math.atan2(-t[1], t[0])
|
|
// along the run (lengthen / shorten), then across it (swing — tangent
|
|
// rotated ±90°).
|
|
const dirs: { dir: [number, number]; along: boolean }[] = tangentXZ
|
|
? [
|
|
{ dir: [t[0], t[1]], along: true },
|
|
{ dir: [-t[0], -t[1]], along: true },
|
|
{ dir: [-t[1], t[0]], along: false },
|
|
{ dir: [t[1], -t[0]], along: false },
|
|
]
|
|
: [
|
|
{ dir: [1, 0], along: false },
|
|
{ dir: [-1, 0], along: false },
|
|
{ dir: [0, 1], along: false },
|
|
{ dir: [0, -1], along: false },
|
|
]
|
|
// Inward (toward the run body) at an endpoint: i=0 the tangent points at
|
|
// the neighbour, i=last it points away — so inward is ∓tangent. Interior
|
|
// points keep all four arrows.
|
|
const inward: [number, number] | null =
|
|
tangentXZ && i === 0 ? [t[0], t[1]] : tangentXZ && i === last ? [-t[0], -t[1]] : null
|
|
for (const { dir, along } of dirs) {
|
|
const [dx, dz] = dir
|
|
if (inward && dx * inward[0] + dz * inward[1] > 0.999) continue
|
|
arrows.push({
|
|
key: `pt${i}-${dx.toFixed(3)}:${dz.toFixed(3)}`,
|
|
index: i,
|
|
kind: { axis: 'horizontal', dir: [dx, dz], along },
|
|
position: [p[0] + dx * base, p[1], p[2] + dz * base],
|
|
rotationY: Math.atan2(-dz, dx),
|
|
cursor: 'grab',
|
|
})
|
|
}
|
|
// Inward at a vertical endpoint is the chevron that points back toward the
|
|
// neighbour; hide it so a riser endpoint exposes one clear outer length
|
|
// handle along the duct instead of a misleading up/down swing pair.
|
|
const inwardY =
|
|
verticalTangentY && i === 0
|
|
? verticalTangentY
|
|
: verticalTangentY && i === last
|
|
? -verticalTangentY
|
|
: null
|
|
for (const sign of [1, -1] as const) {
|
|
if (inwardY === sign) continue
|
|
arrows.push({
|
|
key: `pt${i}-${sign > 0 ? 'up' : 'down'}`,
|
|
index: i,
|
|
kind: { axis: 'y', along: verticalTangentY !== null },
|
|
position: [p[0], p[1] + sign * base, p[2]],
|
|
rotationY: runYaw,
|
|
vertical: sign > 0 ? 'up' : 'down',
|
|
cursor: 'ns-resize',
|
|
})
|
|
}
|
|
})
|
|
return arrows
|
|
}
|
|
|
|
/**
|
|
* Node-local XZ unit tangent of the run at vertex `i`: toward the neighbour at
|
|
* an endpoint, the averaged direction of the two adjacent segments at an
|
|
* interior corner. Null when the run has no horizontal extent there (a pure
|
|
* riser vertex), so the caller falls back to a world-aligned arrow.
|
|
*/
|
|
function vertexTangentXZ(duct: DuctSegmentNode, i: number): [number, number] | null {
|
|
const path = duct.path
|
|
const last = path.length - 1
|
|
if (last < 1) return null
|
|
const seg = (a: number, b: number): [number, number] | null => {
|
|
const dx = path[b]![0] - path[a]![0]
|
|
const dz = path[b]![2] - path[a]![2]
|
|
const len = Math.hypot(dx, dz)
|
|
return len < 1e-6 ? null : [dx / len, dz / len]
|
|
}
|
|
if (i === 0) return seg(0, 1)
|
|
if (i === last) return seg(last - 1, last)
|
|
const inc = seg(i - 1, i)
|
|
const out = seg(i, i + 1)
|
|
if (!inc) return out
|
|
if (!out) return inc
|
|
const sx = inc[0] + out[0]
|
|
const sz = inc[1] + out[1]
|
|
const len = Math.hypot(sx, sz)
|
|
return len < 1e-6 ? inc : [sx / len, sz / len]
|
|
}
|
|
|
|
/**
|
|
* Node-local Y tangent for a vertex whose adjacent run direction is vertical.
|
|
* Uses the same endpoint orientation convention as `vertexTangentXZ`: start
|
|
* points toward its neighbour, end points away from its previous neighbour.
|
|
*/
|
|
function vertexTangentY(duct: DuctSegmentNode, i: number): 1 | -1 | null {
|
|
const path = duct.path
|
|
const last = path.length - 1
|
|
if (last < 1) return null
|
|
const seg = (a: number, b: number): 1 | -1 | null => {
|
|
const dx = path[b]![0] - path[a]![0]
|
|
const dy = path[b]![1] - path[a]![1]
|
|
const dz = path[b]![2] - path[a]![2]
|
|
if (Math.hypot(dx, dz) > 1e-6 || Math.abs(dy) < 1e-6) return null
|
|
return dy > 0 ? 1 : -1
|
|
}
|
|
if (i === 0) return seg(0, 1)
|
|
if (i === last) return seg(last - 1, last)
|
|
const inc = seg(i - 1, i)
|
|
const out = seg(i, i + 1)
|
|
if (!inc) return out
|
|
if (!out) return inc
|
|
return inc === out ? inc : null
|
|
}
|
|
|
|
/** Y-yaw that aligns a handle's local +X with the run tangent at vertex `i`. */
|
|
function vertexYaw(duct: DuctSegmentNode, i: number): number {
|
|
const t = vertexTangentXZ(duct, i)
|
|
return t ? Math.atan2(-t[1], t[0]) : 0
|
|
}
|
|
|
|
/**
|
|
* Overall run direction and midpoint (node-local), for the roll gizmo. The
|
|
* direction is path[0]→last (the run's gross axis), falling back to the first
|
|
* segment when the ends coincide; the centre is their midpoint. Null when the
|
|
* run has no length to roll about.
|
|
*/
|
|
function runAxisAndCenter(duct: DuctSegmentNode): { dir: Point; center: Point } | null {
|
|
const path = duct.path
|
|
if (path.length < 2) return null
|
|
const a = path[0]!
|
|
const b = path[path.length - 1]!
|
|
let dx = b[0] - a[0]
|
|
let dy = b[1] - a[1]
|
|
let dz = b[2] - a[2]
|
|
let len = Math.hypot(dx, dy, dz)
|
|
if (len < 1e-6) {
|
|
const c = path[1]!
|
|
dx = c[0] - a[0]
|
|
dy = c[1] - a[1]
|
|
dz = c[2] - a[2]
|
|
len = Math.hypot(dx, dy, dz)
|
|
if (len < 1e-6) return null
|
|
}
|
|
return {
|
|
dir: [dx / len, dy / len, dz / len],
|
|
center: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2],
|
|
}
|
|
}
|
|
|
|
export default DuctSegmentSelectionAffordance
|