218 lines
9.5 KiB
TypeScript
218 lines
9.5 KiB
TypeScript
import type { AnyNodeId, GutterNode, RoofSegmentNode, SceneApi } from '@pascal-app/core'
|
||
|
||
/**
|
||
* Length-handle snap. When the user drags a gutter's ±X length handle
|
||
* and the proposed endpoint lands within `SNAP_RADIUS` of the geometric
|
||
* CORNER it would form with another gutter — the intersection of their
|
||
* two length-axis lines — the dragged gutter's length is pulled so its
|
||
* end lands exactly on that corner. The corner-mitre detector's match
|
||
* window then fires reliably without a pixel-perfect drag.
|
||
*
|
||
* ONLY the dragged gutter moves. The corner is the axis crossing — a
|
||
* fixed point in plan, independent of where the other gutter currently
|
||
* ends — so each gutter snaps onto the SAME shared corner on its own as
|
||
* it's dragged in, and the L meets there without ever reaching over to
|
||
* reposition a gutter the user placed deliberately. (An earlier version
|
||
* adjusted both gutters at once; that yanked an already-placed gutter
|
||
* whenever its corner-mate was dragged, which is the bug this avoids.)
|
||
*
|
||
* Cross-segment: the search covers every gutter under the SAME ROOF
|
||
* (not just the dragged gutter's segment-mates), and all the geometry
|
||
* runs in the shared ROOF frame — each gutter's endpoints are lifted
|
||
* out of its own segment-local frame by that segment's position +
|
||
* Y-rotation. So an L-shaped plan whose two eaves live on different
|
||
* roof segments snaps + mitres at the corner where the segments meet,
|
||
* exactly like a same-segment hip corner (which is the degenerate case
|
||
* where both gutters share one segment transform).
|
||
*
|
||
* Pure: no React, no THREE. Reads through SceneApi; returns the snapped
|
||
* length for the caller to apply.
|
||
*/
|
||
|
||
// 10 cm catch radius — wide enough that the user doesn't need pixel-
|
||
// perfect dragging, narrow enough that unrelated gutters on the
|
||
// opposite eave don't accidentally bind.
|
||
const SNAP_RADIUS = 0.1
|
||
const SNAP_RADIUS_SQ = SNAP_RADIUS * SNAP_RADIUS
|
||
|
||
// Cross product below this counts as parallel axes — no intersection,
|
||
// fall back to snapping A onto B's current endpoint without modifying B.
|
||
const AXIS_PARALLEL_EPSILON = 1e-3
|
||
|
||
// How far a corner-mate's own nearer endpoint may sit from the corner
|
||
// and still bind the dragged gutter to it. The corner is the crossing
|
||
// of the two axis LINES, which can lie well beyond where a gutter ends —
|
||
// at an inner/concave corner the mate's end is a full eave overhang
|
||
// short of the crossing, so the bound has to clear a generous overhang.
|
||
// It also stops a far perpendicular gutter, whose infinite axis happens
|
||
// to cross near the dragged end, from binding by coincidence: a real
|
||
// corner-mate is within reach, an unrelated run is metres away.
|
||
const CORNER_MATE_REACH = 1.5
|
||
const CORNER_MATE_REACH_SQ = CORNER_MATE_REACH * CORNER_MATE_REACH
|
||
|
||
export type GutterLengthSnap = {
|
||
/** Length to apply to the dragged gutter. */
|
||
length: number
|
||
}
|
||
|
||
/**
|
||
* @param initial gutter at drag start (rotation, length, position)
|
||
* @param proposedLength length the linear-resize pipeline computed
|
||
* @param sign +1 for the gutter-local +X end being dragged, −1 for −X
|
||
* @param anchorX,anchorZ the held-fixed endpoint (opposite of `sign`)
|
||
* @param armX,armZ gutter +X direction in segment frame (cos r, −sin r)
|
||
* @param minLength floor — typically the descriptor's `min` value
|
||
* @param sceneApi scene access for corner-mate lookup
|
||
*/
|
||
export function snapLengthToCorner(
|
||
initial: GutterNode,
|
||
proposedLength: number,
|
||
sign: 1 | -1,
|
||
anchorX: number,
|
||
anchorZ: number,
|
||
armX: number,
|
||
armZ: number,
|
||
minLength: number,
|
||
sceneApi: SceneApi,
|
||
): GutterLengthSnap {
|
||
const segmentId = initial.roofSegmentId as AnyNodeId | undefined
|
||
if (!segmentId) return { length: proposedLength }
|
||
const seg = sceneApi.get<RoofSegmentNode>(segmentId)
|
||
if (!seg) return { length: proposedLength }
|
||
|
||
// Everything runs in the ROOF frame so gutters on different segments
|
||
// can meet. The dragged gutter's anchor/arm come in segment-local
|
||
// (the caller computes them from `initial`); lift them into the roof
|
||
// frame with the dragged segment's transform.
|
||
const selfTf = segmentTransform(seg)
|
||
const anchorR = applyTf(selfTf, anchorX, anchorZ)
|
||
const armR = applyTfDir(selfTf, armX, armZ)
|
||
const aAnchorX = anchorR.x
|
||
const aAnchorZ = anchorR.z
|
||
const aArmX = armR.x
|
||
const aArmZ = armR.z
|
||
|
||
const proposedEndX = aAnchorX + sign * proposedLength * aArmX
|
||
const proposedEndZ = aAnchorZ + sign * proposedLength * aArmZ
|
||
|
||
// Candidate gutters: every gutter under the SAME ROOF, each carrying
|
||
// its own segment's roof-frame transform.
|
||
type Cand = { gutter: GutterNode; tf: SegmentTransform }
|
||
const candidates: Cand[] = []
|
||
const roofId = seg.parentId as AnyNodeId | undefined
|
||
const roof = roofId ? sceneApi.get(roofId) : undefined
|
||
const roofChildren = (roof as { children?: readonly string[] } | undefined)?.children
|
||
for (const sid of roofChildren ?? []) {
|
||
const s = sceneApi.get<RoofSegmentNode>(sid as AnyNodeId)
|
||
if (s?.type !== 'roof-segment') continue
|
||
const tf = segmentTransform(s)
|
||
for (const gid of s.children ?? []) {
|
||
const g = sceneApi.get(gid as AnyNodeId)
|
||
if (g?.type === 'gutter' && g.id !== initial.id) {
|
||
candidates.push({ gutter: g as GutterNode, tf })
|
||
}
|
||
}
|
||
}
|
||
|
||
// Find the corner-mate whose CORNER with the dragged gutter lands
|
||
// closest to the proposed dragged endpoint. The corner is the
|
||
// intersection of the two length-axis LINES (roof frame) — NOT the
|
||
// proximity of the two endpoints. That distinction is what unlocks
|
||
// inner/concave corners: there the two eave drip-lines meet out in the
|
||
// notch, a full overhang away from where either gutter naturally ends,
|
||
// so the old endpoint-to-endpoint catch never fired. Keying off the
|
||
// axis crossing treats convex and concave identically. Parallel axes
|
||
// (a straight collinear run) have no crossing, so there we fall back to
|
||
// the mate's nearer endpoint (flush join). Only the dragged gutter's
|
||
// own length is snapped to the corner — the mate is never moved.
|
||
let bestTargetX = 0
|
||
let bestTargetZ = 0
|
||
let bestDistSq = SNAP_RADIUS_SQ
|
||
let found = false
|
||
|
||
for (const { gutter: mateG, tf } of candidates) {
|
||
const mateRot = mateG.rotation ?? 0
|
||
const arm = applyTfDir(tf, Math.cos(mateRot), -Math.sin(mateRot))
|
||
const mateHalf = mateG.length / 2
|
||
const center = applyTf(tf, mateG.position[0], mateG.position[2])
|
||
const plusX = center.x + arm.x * mateHalf
|
||
const plusZ = center.z + arm.z * mateHalf
|
||
const minusX = center.x - arm.x * mateHalf
|
||
const minusZ = center.z - arm.z * mateHalf
|
||
|
||
// Corner target T: axis intersection when the runs cross, else the
|
||
// mate's endpoint nearest the dragged end (collinear extension).
|
||
const crossDirs = aArmX * arm.z - aArmZ * arm.x
|
||
let targetX: number
|
||
let targetZ: number
|
||
if (Math.abs(crossDirs) < AXIS_PARALLEL_EPSILON) {
|
||
const dPlus = (plusX - proposedEndX) ** 2 + (plusZ - proposedEndZ) ** 2
|
||
const dMinus = (minusX - proposedEndX) ** 2 + (minusZ - proposedEndZ) ** 2
|
||
if (dPlus <= dMinus) {
|
||
targetX = plusX
|
||
targetZ = plusZ
|
||
} else {
|
||
targetX = minusX
|
||
targetZ = minusZ
|
||
}
|
||
} else {
|
||
const dx = center.x - aAnchorX
|
||
const dz = center.z - aAnchorZ
|
||
const t = (dx * arm.z - dz * arm.x) / crossDirs
|
||
targetX = aAnchorX + t * aArmX
|
||
targetZ = aAnchorZ + t * aArmZ
|
||
}
|
||
|
||
// Reject a mate whose own ends are nowhere near the crossing — its
|
||
// infinite axis lines up by coincidence, it's not a real corner-mate.
|
||
const dPlusT = (plusX - targetX) ** 2 + (plusZ - targetZ) ** 2
|
||
const dMinusT = (minusX - targetX) ** 2 + (minusZ - targetZ) ** 2
|
||
if (Math.min(dPlusT, dMinusT) > CORNER_MATE_REACH_SQ) continue
|
||
|
||
const score = (targetX - proposedEndX) ** 2 + (targetZ - proposedEndZ) ** 2
|
||
if (score < bestDistSq) {
|
||
bestDistSq = score
|
||
bestTargetX = targetX
|
||
bestTargetZ = targetZ
|
||
found = true
|
||
}
|
||
}
|
||
|
||
if (!found) return { length: proposedLength }
|
||
|
||
// Snap the dragged gutter's own end onto the corner: project
|
||
// (corner − anchor) onto its roof-frame axis. Length is frame-invariant
|
||
// (a scalar along the run), so the projection is the same in roof or
|
||
// segment frame — no need to map back. The mate is left untouched.
|
||
const projected = sign * ((bestTargetX - aAnchorX) * aArmX + (bestTargetZ - aAnchorZ) * aArmZ)
|
||
return { length: Math.max(minLength, projected) }
|
||
}
|
||
|
||
// ─── Segment-frame ↔ roof-frame transform ────────────────────────────
|
||
//
|
||
// A segment places its children at `seg.position` rotated by
|
||
// `seg.rotation` about +Y. THREE's rotation-y convention: a point
|
||
// (x, z) maps to (x·cos + z·sin, −x·sin + z·cos). These helpers lift a
|
||
// gutter's segment-local X/Z into the shared roof frame and back so two
|
||
// gutters on different segments can be compared in one frame.
|
||
|
||
type SegmentTransform = { x: number; z: number; cos: number; sin: number }
|
||
|
||
function segmentTransform(seg: Pick<RoofSegmentNode, 'position' | 'rotation'>): SegmentTransform {
|
||
const r = seg.rotation ?? 0
|
||
return {
|
||
x: seg.position?.[0] ?? 0,
|
||
z: seg.position?.[2] ?? 0,
|
||
cos: Math.cos(r),
|
||
sin: Math.sin(r),
|
||
}
|
||
}
|
||
|
||
function applyTf(tf: SegmentTransform, x: number, z: number): { x: number; z: number } {
|
||
return { x: tf.x + (x * tf.cos + z * tf.sin), z: tf.z + (-x * tf.sin + z * tf.cos) }
|
||
}
|
||
|
||
function applyTfDir(tf: SegmentTransform, x: number, z: number): { x: number; z: number } {
|
||
return { x: x * tf.cos + z * tf.sin, z: -x * tf.sin + z * tf.cos }
|
||
}
|