184 lines
7.1 KiB
TypeScript

import type { AnyNode, AnyNodeId, DuctFittingNode, PipeFittingNode } from '@pascal-app/core'
import { getDuctFittingPorts } from '../duct-fitting/ports'
import { getPipeFittingPorts } from '../pipe-fitting/ports'
import { planElbowRealign, planPipeElbowRealign, planTeeBranchRealign } from './auto-fitting'
/**
* Shared "drag a run end, the connected fitting re-aims" logic for the
* selection-time endpoint drag — duct (`duct-segment`) and DWV pipe
* (`pipe-segment`) alike, plus their 2D `move-path-point` twins.
*
* Two re-aim shapes share this path:
*
* - **Elbow** (duct + pipe): when you grab the free end of a straight run
* whose OTHER end sits on an elbow collar, the elbow's junction and far
* (mated) collar stay put while the near collar swings to face the
* dragged end — the bend `angle` adjusts to fit. Mirrors a wall corner.
*
* - **Tee branch** (duct only): when you grab the free end of a run mated
* to a tee's BRANCH collar, the tee's run legs stay locked to the trunk
* and only its `branchAngle` swings, so the branch keeps pointing at the
* dragged end.
*
* Detection runs ONCE at drag start (`detectFittingEndpoint`) against a
* snapshot of the fitting; the per-frame plan (`planFittingEndpointReaim`)
* always re-derives from that original snapshot, so live mutation of the
* fitting never compounds.
*/
type Point = [number, number, number]
/** Distance (m) under which a run end counts as sitting on a fitting collar —
* matches core's port-coincidence epsilon. */
const COINCIDENT_EPS_M = 0.05
/** Which run kind we're editing decides which fitting kind to look for. */
type ReaimFitting = DuctFittingNode | PipeFittingNode
export type FittingEndpoint = {
/** The fitting node as it stood at drag start (the stable reference). */
fitting: ReaimFitting
/** Whether the re-aim re-orients the whole elbow body or just swings a
* duct tee's branch lean. */
reaim: 'elbow' | 'tee-branch'
/** Which fitting collar the run's non-dragged end is mated to. */
portId: 'inlet' | 'outlet' | 'branch'
/** The fitting kind, so the per-frame plan calls the right realign. */
fittingType: 'duct-fitting' | 'pipe-fitting'
/** Patch that restores the fitting to its drag-start state, for the
* single-undo dance's pre-resume revert. */
revert: { id: AnyNodeId; data: Partial<AnyNode> }
}
export type FittingEndpointReaimPlan = {
/** New path for the dragged run: the dragged end at the cursor, the
* fitting end pulled onto the re-aimed collar. */
path: Point[]
/** Patch re-aiming the fitting (elbow: angle + rotation; tee: branchAngle). */
fittingUpdate: { id: AnyNodeId; data: Partial<AnyNode> }
}
/** A run kind ('duct-segment' / 'pipe-segment') → the fitting kind it
* mates to. Anything else has no re-aim. */
function fittingTypeForRun(runKind: string): 'duct-fitting' | 'pipe-fitting' | null {
if (runKind === 'duct-segment') return 'duct-fitting'
if (runKind === 'pipe-segment') return 'pipe-fitting'
return null
}
function distSq(a: Point | readonly number[], b: Point | readonly number[]): number {
const dx = a[0]! - b[0]!
const dy = a[1]! - b[1]!
const dz = a[2]! - b[2]!
return dx * dx + dy * dy + dz * dz
}
/**
* If `runPath` is a straight two-point run whose NON-dragged end sits on a
* fitting collar that can re-aim, return that fitting snapshot + the mated
* port id and re-aim shape. `runKind` selects which fitting kind to scan
* for. Elbow inlet/outlet collars re-aim the whole elbow; a duct tee's
* branch collar swings only the branch. Otherwise null — the caller falls
* back to plain free-drag.
*/
export function detectFittingEndpoint(
runKind: string,
runPath: ReadonlyArray<readonly [number, number, number]>,
draggedIndex: number,
nodes: Record<string, AnyNode>,
): FittingEndpoint | null {
if (runPath.length !== 2) return null
const fittingType = fittingTypeForRun(runKind)
if (!fittingType) return null
const fittingEnd = runPath[draggedIndex === 0 ? 1 : 0]!
const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M
for (const node of Object.values(nodes)) {
if (!node || node.type !== fittingType) continue
const fitting = node as ReaimFitting
const isElbow = fitting.fittingType === 'elbow'
// Tee-branch re-aim is duct-only (a sanitary tee has no adjustable
// branch lean).
const isDuctTee = fittingType === 'duct-fitting' && fitting.fittingType === 'tee'
if (!isElbow && !isDuctTee) continue
const ports =
fittingType === 'duct-fitting'
? getDuctFittingPorts(fitting as DuctFittingNode)
: getPipeFittingPorts(fitting as PipeFittingNode)
for (const port of ports) {
if (isElbow && port.id !== 'inlet' && port.id !== 'outlet') continue
if (isDuctTee && port.id !== 'branch') continue
if (distSq(port.position, fittingEnd) > eps2) continue
if (isElbow) {
return {
fitting,
reaim: 'elbow',
portId: port.id as 'inlet' | 'outlet',
fittingType,
revert: {
id: fitting.id as AnyNodeId,
data: { angle: fitting.angle, rotation: fitting.rotation } as Partial<AnyNode>,
},
}
}
return {
fitting,
reaim: 'tee-branch',
portId: 'branch',
fittingType,
revert: {
id: fitting.id as AnyNodeId,
data: { branchAngle: (fitting as DuctFittingNode).branchAngle } as Partial<AnyNode>,
},
}
}
}
return null
}
/**
* Plan the run path + fitting re-aim for the dragged end at `draggedPoint`.
* The fitting swings its mated collar to face the junction→cursor direction;
* the run goes from that collar to the cursor. Returns null when the
* required turn falls outside the fitting's buildable range (caller keeps
* the plain free-drag for that frame).
*/
export function planFittingEndpointReaim(
endpoint: FittingEndpoint,
draggedIndex: number,
draggedPoint: Point,
): FittingEndpointReaimPlan | null {
const { fitting, reaim, portId, fittingType } = endpoint
const j = fitting.position
const away: Point = [draggedPoint[0] - j[0], draggedPoint[1] - j[1], draggedPoint[2] - j[2]]
if (away[0] * away[0] + away[1] * away[1] + away[2] * away[2] < 1e-10) return null
if (reaim === 'tee-branch') {
const realign = planTeeBranchRealign(fitting as DuctFittingNode, away)
if (!realign) return null
const path: Point[] =
draggedIndex === 0 ? [draggedPoint, realign.collarPoint] : [realign.collarPoint, draggedPoint]
return {
path,
fittingUpdate: {
id: realign.update.id as AnyNodeId,
data: realign.update.data as Partial<AnyNode>,
},
}
}
const realign =
fittingType === 'duct-fitting'
? planElbowRealign(fitting as DuctFittingNode, portId, away)
: planPipeElbowRealign(fitting as PipeFittingNode, portId, away)
if (!realign) return null
const path: Point[] =
draggedIndex === 0 ? [draggedPoint, realign.collarPoint] : [realign.collarPoint, draggedPoint]
return {
path,
fittingUpdate: {
id: realign.update.id as AnyNodeId,
data: realign.update.data as Partial<AnyNode>,
},
}
}