Skip to main content

brep_kernel/feature_pipeline/assembly/
mapping.rs

1//! Constraint → mate mapping SHARED MACHINERY: selection-ref resolution
2//! against the scene's component registry, the [`map_constraint`] dispatch,
3//! the explicit mate facing (`facing`: aligned / opposed, see
4//! [`effective_align`]), first-solve initialization, and the rigid-pose math
5//! shared with the write-back lane. The PER-TYPE `map` functions live with
6//! their constraints ([`super::constraints`], one module per type).
7//!
8//! # Selection-ref conventions (cross-lane contract)
9//!
10//! - Face/edge refs are the NAMESPACED scene names (`ACOMP2:Extrude1_top`).
11//! - A whole-component ref is the bare feature id (`ACOMP2`), or a nested
12//!   chain (`ACOMP3:ACOMP1`) owned by the OUTERMOST component at this level.
13//! - A vertex ref is `{solidName}@x,y,z` with the position in COMPONENT-LOCAL
14//!   coordinates (stable under pose changes; kernel vertices carry no names).
15//!   The picker builds it as `world_pick · component_transform⁻¹`.
16
17use super::ConstraintEntry;
18use crate::feature_pipeline::component::ComponentRecord;
19use crate::feature_pipeline::{Env, SceneMap};
20use crate::{
21    resolve_edge_selection, resolve_face_selection, resolve_named_selection,
22    resolve_vertex_selection, AffineTransform, MateAlign, MateKind, SelectionGeometry, Vec3,
23};
24
25/// A per-constraint failure carrying the requirements-§5 status word.
26#[derive(Debug, Clone)]
27pub(super) struct ConstraintFailure {
28    pub status: &'static str,
29    pub message: String,
30}
31
32impl ConstraintFailure {
33    pub fn new(status: &'static str, message: impl Into<String>) -> Self {
34        Self {
35            status,
36            message: message.into(),
37        }
38    }
39    pub(super) fn unsupported(message: impl Into<String>) -> Self {
40        Self::new("unsupported-selection", message)
41    }
42    pub(super) fn invalid(message: impl Into<String>) -> Self {
43        Self::new("invalid-selection", message)
44    }
45}
46
47// ===========================================================================
48// Element resolution
49// ===========================================================================
50
51/// One resolved constraint element: the owning component plus the analytic
52/// frame in WORLD space (as stored on the resident, world-posed solids) and in
53/// the component's LOCAL space (the solver's mate-input space).
54#[derive(Debug, Clone)]
55pub(super) struct ResolvedElement {
56    pub name: String,
57    pub component: String,
58    pub world: SelectionGeometry,
59    pub local: SelectionGeometry,
60    pub transform: AffineTransform,
61    /// An edge selection's carrier over its range, component-LOCAL — what the
62    /// curve mates (path) follow. `None` for anything but an edge.
63    pub curve: Option<crate::MateCurve>,
64    /// The edge is neither straight nor circular: it resolves (as its chord
65    /// midpoint) only so a curve mate can follow it; every other type refuses
66    /// it with `refusal`, as before.
67    pub freeform: Option<ConstraintFailure>,
68}
69
70/// Resolve one selection ref (conventions in the module doc). Every failure is
71/// a typed status — never a panic; non-component geometry is fenced here
72/// (requirements: solids from ordinary modeling features do not participate).
73pub(super) fn resolve_element(
74    scene: &SceneMap,
75    name: &str,
76) -> Result<ResolvedElement, ConstraintFailure> {
77    let record = scene.owning_component(name).ok_or_else(|| {
78        ConstraintFailure::invalid(format!(
79            "selection '{name}' does not belong to an assembly component — only component geometry participates in constraints"
80        ))
81    })?;
82    if let Some(follow) = &record.pattern {
83        return Err(ConstraintFailure::invalid(format!(
84            "'{}' is instance {} of pattern {}: it follows its seed {} and takes no mates of its own — mate {} instead",
85            record.id, follow.key, follow.pattern_id, follow.seed, follow.seed
86        )));
87    }
88    let curve = edge_curve(scene, name, &record.transform)?;
89    let (world, freeform) = match resolve_world_geometry(scene, name, record) {
90        Ok(world) => (world, None),
91        Err(failure) if failure.status == "unsupported-selection" && curve.is_some() => {
92            let chord = curve.as_ref().and_then(|c| {
93                let mid = c.curve.evaluate(0.5 * (c.t0 + c.t1)).ok()?;
94                Some(record.transform.point(mid))
95            });
96            match chord {
97                Some(position) => (SelectionGeometry::Point { position }, Some(failure)),
98                None => return Err(failure),
99            }
100        }
101        Err(failure) => return Err(failure),
102    };
103    let inverse = record.transform.rigid_inverse().map_err(|error| {
104        ConstraintFailure::new(
105            "error",
106            format!("component '{}': non-rigid pose: {error}", record.id),
107        )
108    })?;
109    let local = world.transformed(&inverse).map_err(|error| {
110        ConstraintFailure::new("error", format!("selection '{name}': {error}"))
111    })?;
112    Ok(ResolvedElement {
113        curve,
114        freeform,
115        name: name.to_string(),
116        component: record.id.clone(),
117        world,
118        local,
119        transform: record.transform,
120    })
121}
122
123/// An edge selection's carrier over its range, in the component's LOCAL
124/// frame; `None` when `name` is not an edge.
125fn edge_curve(
126    scene: &SceneMap,
127    name: &str,
128    transform: &AffineTransform,
129) -> Result<Option<crate::MateCurve>, ConstraintFailure> {
130    let Some(edge) = scene.resolve_edge(name) else { return Ok(None) };
131    let inverse = transform.rigid_inverse().map_err(ConstraintFailure::invalid)?;
132    crate::with_registered_solid_str(edge.handle, |solid| {
133        let Some(record) = solid.edges.iter().find(|record| record.id == edge.edge_id) else {
134            return Ok(None);
135        };
136        let curve = crate::transform_topology::transform_curve(&record.curve, inverse)?;
137        Ok(Some(crate::MateCurve { curve, t0: record.t0, t1: record.t1 }))
138    })
139    .map_err(ConstraintFailure::invalid)
140}
141
142fn resolve_world_geometry(
143    scene: &SceneMap,
144    name: &str,
145    record: &ComponentRecord,
146) -> Result<SelectionGeometry, ConstraintFailure> {
147    // Vertex ref: `{solid}@x,y,z`, component-local position.
148    if let Some((solid_name, coords)) = name.split_once('@') {
149        let handle = scene.resolve_solid(solid_name).ok_or_else(|| {
150            ConstraintFailure::invalid(format!("vertex ref '{name}': unknown solid '{solid_name}'"))
151        })?;
152        let local = parse_triple(coords).ok_or_else(|| {
153            ConstraintFailure::invalid(format!(
154                "vertex ref '{name}': position must be 'x,y,z' numbers"
155            ))
156        })?;
157        let world_query = record.transform.point(local);
158        return crate::with_registered_solid_str(handle, |solid| {
159            Ok(resolve_vertex_selection(solid, world_query))
160        })
161        .map_err(ConstraintFailure::invalid)?
162        .map_err(|error| ConstraintFailure::new(error.status(), error.to_string()));
163    }
164
165    // Whole-component ref: the bare id, or a nested `ACOMP3:ACOMP1` chain
166    // (representative point over the OUTER record's members under that
167    // prefix). `owning_component` already proved the outermost segment.
168    let (_, local_name) = crate::split_component_namespace(name);
169    if crate::is_component_reference(local_name) {
170        let prefix = format!("{name}:");
171        let members: Vec<(String, u32)> = scene
172            .component_solids(&record.id)
173            .into_iter()
174            .filter(|(member, _)| record.id == name || member.starts_with(&prefix))
175            .collect();
176        if members.is_empty() {
177            return Err(ConstraintFailure::invalid(format!(
178                "component ref '{name}' has no member solids"
179            )));
180        }
181        return component_point(&members);
182    }
183
184    // Named topology: faces first, then edges (the deterministic-naming scheme
185    // never collides across the kinds).
186    if let Some(face) = scene.resolve_face(name) {
187        return crate::with_registered_solid_str(face.handle, |solid| {
188            Ok(resolve_face_selection(solid, face.face_id))
189        })
190        .map_err(ConstraintFailure::invalid)?
191        .map_err(|error| ConstraintFailure::new(error.status(), error.to_string()));
192    }
193    if let Some(edge) = scene.resolve_edge(name) {
194        return crate::with_registered_solid_str(edge.handle, |solid| {
195            Ok(resolve_edge_selection(solid, edge.edge_id))
196        })
197        .map_err(ConstraintFailure::invalid)?
198        .map_err(|error| ConstraintFailure::new(error.status(), error.to_string()));
199    }
200    // A member-solid name (rare — the app selects components, not solids):
201    // resolve like a whole-component anchor over that one solid.
202    if let Some(handle) = scene.resolve_solid(name) {
203        return component_point(&[(name.to_string(), handle)]);
204    }
205    // Last resort: an unregistered per-solid name (should not happen for an
206    // intact scene) — search the component's members by exact name.
207    for (_member, handle) in scene.component_solids(&record.id) {
208        let found = crate::with_registered_solid_str(handle, |solid| {
209            Ok(resolve_named_selection(solid, name).ok())
210        })
211        .map_err(ConstraintFailure::invalid)?;
212        if let Some(geometry) = found {
213            return Ok(geometry);
214        }
215    }
216    Err(ConstraintFailure::invalid(format!(
217        "selection '{name}' not found in the scene"
218    )))
219}
220
221/// Representative anchor of a solid set: center of the aggregate AABB over
222/// topology vertices + surface control-point hulls (bounds the exact surfaces
223/// without tessellation — same construction as `resolve_component_point`,
224/// evaluated per resident member under one short borrow each).
225fn component_point(members: &[(String, u32)]) -> Result<SelectionGeometry, ConstraintFailure> {
226    let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
227    let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
228    let mut any = false;
229    for (member, handle) in members {
230        let (lo, hi, non_empty) = crate::with_registered_solid_str(*handle, |solid| {
231            let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
232            let mut hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
233            let mut non_empty = false;
234            let mut include = |point: Vec3| {
235                lo = Vec3::new(lo.x.min(point.x), lo.y.min(point.y), lo.z.min(point.z));
236                hi = Vec3::new(hi.x.max(point.x), hi.y.max(point.y), hi.z.max(point.z));
237                non_empty = true;
238            };
239            for vertex in &solid.vertices {
240                include(vertex.point);
241            }
242            for shell in &solid.shells {
243                for face in &shell.faces {
244                    for row in &face.surface.control_points {
245                        for control in row {
246                            include(control.point()?);
247                        }
248                    }
249                }
250            }
251            Ok((lo, hi, non_empty))
252        })
253        .map_err(|error| {
254            ConstraintFailure::invalid(format!("component member '{member}': {error}"))
255        })?;
256        if non_empty {
257            low = Vec3::new(low.x.min(lo.x), low.y.min(lo.y), low.z.min(lo.z));
258            high = Vec3::new(high.x.max(hi.x), high.y.max(hi.y), high.z.max(hi.z));
259            any = true;
260        }
261    }
262    if !any {
263        return Err(ConstraintFailure::invalid(
264            "component has no geometry to anchor",
265        ));
266    }
267    Ok(SelectionGeometry::Point {
268        position: low.add(high).scale(0.5),
269    })
270}
271
272fn parse_triple(coords: &str) -> Option<Vec3> {
273    let mut parts = coords.split(',').map(str::trim);
274    let x = parts.next()?.parse::<f64>().ok()?;
275    let y = parts.next()?.parse::<f64>().ok()?;
276    let z = parts.next()?.parse::<f64>().ok()?;
277    if parts.next().is_some() {
278        return None;
279    }
280    Some(Vec3::new(x, y, z))
281}
282
283// ===========================================================================
284// Mate construction
285// ===========================================================================
286
287/// One mapped mate: local geometry sides keyed by owning component id.
288#[derive(Debug, Clone)]
289pub(super) struct MappedMate {
290    pub body_a: String,
291    pub body_b: String,
292    /// The third component a three-body mate reads (a symmetric mate's
293    /// mirror plane); `None` for every two-body mate.
294    pub body_c: Option<String>,
295    pub kind: MateKind,
296}
297
298/// A constraint's whole mapping: its mates, the `inputParams` /
299/// `persistentData` write-backs to commit ON SOLVE SUCCESS (first-solve
300/// initialization), and the current measured value for the overlay label.
301#[derive(Debug, Clone, Default)]
302pub(super) struct MappedConstraint {
303    pub mates: Vec<MappedMate>,
304    pub pending_params: Vec<(String, serde_json::Value)>,
305    pub pending_persistent: Vec<(String, serde_json::Value)>,
306    /// `(value, unit)` — the measured current quantity (`"mm"` model units or
307    /// `"deg"`), when the type has a natural scalar.
308    pub measured: Option<(f64, &'static str)>,
309    /// The evaluated target (distance/angle), for the overlay label suffix.
310    pub target: Option<f64>,
311    /// Persistent angle reference in world space for the viewport arc.
312    pub angle_axis: Option<Vec3>,
313    /// The element ROLES a multi-element type inferred, as index groups into
314    /// the entry's `elements` (center: `[width pair, tab]`) — the overlay lane
315    /// draws by role, not by pick order. Empty for the pairing types.
316    pub groups: Vec<Vec<usize>>,
317    /// A per-type sentence appended to the solved status message (center
318    /// names the roles it inferred: "X centred between Y and Z"), so the
319    /// panel shows what the constraint decided from the selection.
320    pub note: Option<String>,
321}
322
323/// Map one validated, resolved constraint onto kernel mates. `resolved` is
324/// the entry's elements in entry order (the lifecycle has already checked the
325/// count against the type's range). `persistent` is the entry's live
326/// `persistentData`; an unset `facing` is captured into `inputParams` here
327/// ([`effective_align`]).
328pub(super) fn map_constraint(
329    entry: &mut ConstraintEntry,
330    resolved: &[ResolvedElement],
331    env: &Env,
332) -> Result<MappedConstraint, ConstraintFailure> {
333    // The pairing types take exactly two; center reads the whole slice.
334    let pair = || -> Result<(&ResolvedElement, &ResolvedElement), ConstraintFailure> {
335        match resolved {
336            [a, b] => Ok((a, b)),
337            _ => Err(ConstraintFailure::invalid(format!(
338                "{} takes exactly two elements ({} given)",
339                entry.constraint_type,
340                resolved.len()
341            ))),
342        }
343    };
344    // A freeform (spline) edge resolves only for the curve-following types.
345    if !matches!(entry.constraint_type.as_str(), "path" | "cam") {
346        if let Some(refusal) = resolved.iter().find_map(|element| element.freeform.clone()) {
347            return Err(refusal);
348        }
349    }
350    match entry.constraint_type.as_str() {
351        "coincident" => {
352            let (a, b) = pair()?;
353            super::constraints::coincident::map_entry(entry, a, b)
354        }
355        "touch_align" => {
356            let (a, b) = pair()?;
357            super::constraints::touch_align::map(entry, a, b)
358        }
359        "parallel" => {
360            let (a, b) = pair()?;
361            super::constraints::parallel::map(entry, a, b)
362        }
363        "distance" => {
364            let (a, b) = pair()?;
365            let mut mapped = super::constraints::distance::map(entry, a, b, env)?;
366            super::constraints::apply_limit(entry, &mut mapped, env, |value| value)?;
367            Ok(mapped)
368        }
369        "angle" => {
370            let (a, b) = pair()?;
371            let mut mapped = super::constraints::angle::map(entry, a, b, env)?;
372            let exterior = entry.flag("exteriorAngle");
373            super::constraints::apply_limit(entry, &mut mapped, env, |value| {
374                if exterior { 180.0 - value } else { value }
375            })?;
376            Ok(mapped)
377        }
378        "concentric" => {
379            let (a, b) = pair()?;
380            super::constraints::concentric::map(entry, a, b)
381        }
382        "perpendicular" => pair().and_then(|(a, b)| super::constraints::perpendicular::map(a, b)),
383        "tangent" => pair().and_then(|(a, b)| super::constraints::tangent::map(a, b)),
384        "center" => super::constraints::center::map(entry, resolved),
385        "symmetric" => super::constraints::symmetric::map(resolved),
386        "gear" => {
387            let (a, b) = pair()?;
388            super::constraints::gear::map(entry, a, b, env)
389        }
390        "rack_pinion" => {
391            let (a, b) = pair()?;
392            super::constraints::rack_pinion::map(entry, a, b, env)
393        }
394        "screw" => {
395            let (a, b) = pair()?;
396            super::constraints::screw::map(entry, a, b, env)
397        }
398        "path" => {
399            let (a, b) = pair()?;
400            super::constraints::path::map(entry, a, b, env)
401        }
402        "slot" => super::constraints::slot::map(entry, resolved, env),
403        "cam" => {
404            let (a, b) = pair()?;
405            super::constraints::cam::map(entry, a, b, env)
406        }
407        other => Err(ConstraintFailure::new(
408            "error",
409            format!("Unknown constraint type: {other}"),
410        )),
411    }
412}
413
414pub(super) fn mate(a: &ResolvedElement, b: &ResolvedElement, kind: MateKind) -> MappedMate {
415    MappedMate {
416        body_a: a.component.clone(),
417        body_b: b.component.clone(),
418        body_c: None,
419        kind,
420    }
421}
422
423/// The world direction a selection carries, if any (plane normal, axis/line
424/// direction, circle axis).
425pub(super) fn direction_of(geometry: &SelectionGeometry) -> Option<Vec3> {
426    match *geometry {
427        SelectionGeometry::Plane { normal, .. } => Some(normal),
428        SelectionGeometry::Axis { direction, .. } | SelectionGeometry::Line { direction, .. } => {
429            Some(direction)
430        }
431        SelectionGeometry::Circle { axis, .. } => Some(axis),
432        SelectionGeometry::Sphere { .. } | SelectionGeometry::Point { .. } => None,
433    }
434}
435
436pub(super) fn local_point(element: &ResolvedElement) -> [f64; 3] {
437    let p = element.local.representative_point();
438    [p.x, p.y, p.z]
439}
440
441pub(super) fn require_direction(element: &ResolvedElement) -> Result<(Vec3, Vec3), ConstraintFailure> {
442    let world = direction_of(&element.world).ok_or_else(|| {
443        ConstraintFailure::unsupported(format!(
444            "selection '{}' carries no direction (needs a planar face, straight edge, axis face, or circular edge)",
445            element.name
446        ))
447    })?;
448    let local = direction_of(&element.local).expect("local mirrors world kind");
449    Ok((world, local))
450}
451
452pub(super) fn require_axis(element: &ResolvedElement) -> Result<crate::MateAxis, ConstraintFailure> {
453    element.local.mate_axis().ok_or_else(|| {
454        ConstraintFailure::unsupported(format!(
455            "selection '{}' carries no axis (needs a cylindrical/conical face, circular edge, or straight edge)",
456            element.name
457        ))
458    })
459}
460
461pub(super) fn require_plane(element: &ResolvedElement) -> Result<crate::MatePlane, ConstraintFailure> {
462    element.local.mate_plane().ok_or_else(|| {
463        ConstraintFailure::unsupported(format!(
464            "selection '{}' is not a planar face",
465            element.name
466        ))
467    })
468}
469
470/// The order-independent selection-pair signature (duplicate detection AND the
471/// orientation-preference recapture key).
472pub(super) fn pair_signature(elements: &[String]) -> String {
473    let mut pair: Vec<&str> = elements.iter().map(String::as_str).collect();
474    pair.sort_unstable();
475    pair.join("\n")
476}
477
478/// The mate's facing: the constraint's own `facing` param (`aligned` /
479/// `opposed`, a dropdown in its form; Flip toggles it). Nothing hidden decides
480/// it (owner 2026-11-05).
481///
482/// A constraint with no `facing` yet — a new one, or one saved before it
483/// existed — takes the facing it is assembled with: the old rule, the
484/// `preferredOppose` cache (or, without one, the current directions) XOR its
485/// `legacy` toggle (`reverse` / `opposeNormals`), so no part moves. That
486/// facing is then WRITTEN into `facing` and the hidden pieces are dropped:
487/// from the first solve on, the form shows what the mate holds.
488pub(super) fn effective_align(
489    entry: &mut ConstraintEntry,
490    dir_a: Vec3,
491    dir_b: Vec3,
492    legacy: &str,
493) -> MateAlign {
494    let dot = dir_a.dot(dir_b);
495    entry.set_persistent("lastOrientationDot", serde_json::json!(dot));
496    match entry.input_params.get(FACING).and_then(|value| value.as_str()) {
497        Some(OPPOSED) => return MateAlign::AntiAligned,
498        Some(_) => return MateAlign::Aligned,
499        None => {}
500    }
501    let signature = pair_signature(&entry.elements());
502    let cached = entry
503        .persistent("preferredOpposeSignature")
504        .and_then(|value| value.as_str())
505        .map(|stored| stored == signature)
506        .unwrap_or(false)
507        .then(|| entry.persistent("preferredOppose").and_then(|v| v.as_bool()))
508        .flatten();
509    let opposed = cached.unwrap_or(dot < 0.0) != entry.flag(legacy);
510    if let Some(params) = entry.input_params.as_object_mut() {
511        params.remove(legacy);
512        params.insert(FACING.into(), serde_json::json!(if opposed { OPPOSED } else { ALIGNED }));
513    }
514    entry.remove_persistent("preferredOppose");
515    entry.remove_persistent("preferredOpposeSignature");
516    if opposed {
517        MateAlign::AntiAligned
518    } else {
519        MateAlign::Aligned
520    }
521}
522
523/// The facing param and its two values.
524pub(super) const FACING: &str = "facing";
525pub(super) const ALIGNED: &str = "aligned";
526pub(super) const OPPOSED: &str = "opposed";
527
528/// The `facing` schema field: a dropdown of the two facings; unset (null)
529/// on a new constraint, which takes the facing it is assembled with.
530pub(super) fn facing_field(hint: &str) -> serde_json::Value {
531    serde_json::json!({
532        "type": "options",
533        "options": [ALIGNED, OPPOSED],
534        "default_value": null,
535        "label": "Facing",
536        "hint": hint,
537    })
538}
539
540
541
542
543
544/// First-solve initialization: when `flag_key` is not yet set in
545/// `persistentData` and the constraint was created WITHOUT `param_key`, the
546/// target ADOPTS the current measurement and both the param and the flag are
547/// queued for commit on solve success. A value given at creation (a script,
548/// the MCP, a dialog) is what was asked for: it drives the parts there, and
549/// only the flag is queued.
550#[allow(clippy::type_complexity)]
551pub(super) fn first_solve_target(
552    entry: &ConstraintEntry,
553    param_key: &str,
554    flag_key: &str,
555    configured: f64,
556    current: f64,
557) -> (
558    f64,
559    (Vec<(String, serde_json::Value)>, Vec<(String, serde_json::Value)>),
560) {
561    let initialized = entry
562        .persistent(flag_key)
563        .and_then(|value| value.as_bool())
564        .unwrap_or(false);
565    let given = entry.input_params.get(param_key).is_some_and(|value| !value.is_null());
566    if initialized {
567        (configured, (Vec::new(), Vec::new()))
568    } else if given {
569        (configured, (Vec::new(), vec![(flag_key.to_string(), serde_json::Value::Bool(true))]))
570    } else {
571        (
572            current,
573            (
574                vec![(param_key.to_string(), serde_json::json!(current))],
575                vec![(flag_key.to_string(), serde_json::Value::Bool(true))],
576            ),
577        )
578    }
579}
580
581
582
583
584
585// ===========================================================================
586// World-space measures (overlay labels + tolerance scaling)
587// ===========================================================================
588
589pub(super) fn angle_between_deg(a: Vec3, b: Vec3) -> f64 {
590    let denominator = a.length() * b.length();
591    if denominator <= 0.0 {
592        return 0.0;
593    }
594    (a.dot(b) / denominator).clamp(-1.0, 1.0).acos().to_degrees()
595}
596
597pub(super) fn point_line_distance(point: Vec3, origin: Vec3, direction: Vec3) -> f64 {
598    let offset = point.sub(origin);
599    let along = offset.dot(direction) / direction.dot(direction).max(1e-300);
600    offset.sub(direction.scale(along)).length()
601}
602
603/// Closest approach between two infinite lines (point-to-line when nearly
604/// parallel — the skew formula degenerates there).
605pub(super) fn line_line_distance(oa: Vec3, da: Vec3, ob: Vec3, db: Vec3) -> f64 {
606    let cross = da.cross(db);
607    let denominator = cross.length();
608    if denominator < 1e-9 * da.length().max(db.length()).max(1.0) {
609        return point_line_distance(ob, oa, da);
610    }
611    (ob.sub(oa).dot(cross) / denominator).abs()
612}
613
614/// The largest coordinate magnitude a constraint's resolved elements span —
615/// the model-scale estimate behind the satisfied-vs-adjusted tolerance.
616pub(super) fn elements_scale(elements: &[ResolvedElement]) -> f64 {
617    let mut scale = 1.0f64;
618    for point in elements.iter().map(|element| element.world.representative_point()) {
619        scale = scale
620            .max(point.x.abs())
621            .max(point.y.abs())
622            .max(point.z.abs());
623    }
624    scale
625}
626
627// ===========================================================================
628// Rigid-pose math (bodies + write-back)
629// ===========================================================================
630
631/// Unit quaternion `[w,x,y,z]` from the rotation rows of a rigid transform
632/// (Shepperd's method — numerically stable for every sign pattern).
633pub(super) fn matrix_to_quaternion(transform: &AffineTransform) -> [f64; 4] {
634    let m = &transform.elements;
635    let (r00, r01, r02) = (m[0], m[1], m[2]);
636    let (r10, r11, r12) = (m[4], m[5], m[6]);
637    let (r20, r21, r22) = (m[8], m[9], m[10]);
638    let trace = r00 + r11 + r22;
639    let q = if trace > 0.0 {
640        let s = (trace + 1.0).sqrt() * 2.0;
641        [s / 4.0, (r21 - r12) / s, (r02 - r20) / s, (r10 - r01) / s]
642    } else if r00 > r11 && r00 > r22 {
643        let s = (1.0 + r00 - r11 - r22).sqrt() * 2.0;
644        [(r21 - r12) / s, s / 4.0, (r01 + r10) / s, (r02 + r20) / s]
645    } else if r11 > r22 {
646        let s = (1.0 + r11 - r00 - r22).sqrt() * 2.0;
647        [(r02 - r20) / s, (r01 + r10) / s, s / 4.0, (r12 + r21) / s]
648    } else {
649        let s = (1.0 + r22 - r00 - r11).sqrt() * 2.0;
650        [(r10 - r01) / s, (r02 + r20) / s, (r12 + r21) / s, s / 4.0]
651    };
652    normalize_quaternion(q)
653}
654
655pub(super) fn normalize_quaternion(q: [f64; 4]) -> [f64; 4] {
656    let norm = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
657    if norm <= 0.0 || !norm.is_finite() {
658        return [1.0, 0.0, 0.0, 0.0];
659    }
660    [q[0] / norm, q[1] / norm, q[2] / norm, q[3] / norm]
661}
662
663/// Rigid transform from a solver pose. The quaternion is RENORMALIZED first —
664/// the write-back contract (`component.rs` rejects non-rigid at 1e-8; a unit
665/// quaternion's matrix is orthonormal to machine precision).
666pub(super) fn pose_to_transform(
667    rotation: [f64; 4],
668    translation: [f64; 3],
669) -> Result<AffineTransform, String> {
670    let [w, x, y, z] = normalize_quaternion(rotation);
671    AffineTransform::new([
672        1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z), 2.0 * (x * z + w * y), translation[0],
673        2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - w * x), translation[1],
674        2.0 * (x * z - w * y), 2.0 * (y * z + w * x), 1.0 - 2.0 * (x * x + y * y), translation[2],
675        0.0, 0.0, 0.0, 1.0,
676    ])
677}
678
679/// The read-side half of [`transform_to_pose_params`]: `{translate,
680/// rotateEulerDeg}` composed exactly as the ACOMP feature composes it (literal
681/// numbers only — expressions are the feature's business).
682pub(super) fn pose_params_to_transform(pose: &serde_json::Value) -> Result<AffineTransform, String> {
683    let triple = |key: &str| -> Result<[f64; 3], String> {
684        let values = pose.get(key).and_then(|value| value.as_array());
685        let Some(values) = values else { return Ok([0.0; 3]) };
686        let mut out = [0.0; 3];
687        for (slot, value) in out.iter_mut().zip(values) {
688            *slot = value.as_f64().ok_or_else(|| format!("pose {key}: not a number"))?;
689        }
690        Ok(out)
691    };
692    let translate = triple("translate")?;
693    let degrees = triple("rotateEulerDeg")?;
694    AffineTransform::new(crate::feature_pipeline::features::common::compose_trs_matrix(
695        translate,
696        [degrees[0].to_radians(), degrees[1].to_radians(), degrees[2].to_radians()],
697        [1.0, 1.0, 1.0],
698        [0.0, 0.0, 0.0],
699    ))
700}
701
702/// The generic `inputParams.transform` pose write-back JSON: `{translate,
703/// rotateEulerDeg}` with degrees in the intrinsic `XYZ` order (`R = Rx·Ry·Rz`
704/// — `features::common::compose_trs_matrix`). This is the write-side half of
705/// the ACOMP feature's reader contract (`assembly_component.rs` reads exactly
706/// `transform.translate` + `transform.rotateEulerDeg`; a mismatched key would
707/// silently zero the pose on the next history run).
708///
709/// `pub` (re-exported as `brep_kernel::transform_to_pose_params`) because it is
710/// the ONE matrix → intrinsic-XYZ-Euler-degrees decomposition in the tree — the
711/// solver write-back and the out-of-crate seams that author ACOMP poses share
712/// it rather than re-deriving the convention (and its gimbal-lock branch), which
713/// the readers on the other side (`assembly_component.rs`, `transform_bake`)
714/// would then silently disagree with.
715pub fn transform_to_pose_params(transform: &AffineTransform) -> serde_json::Value {
716    let m = &transform.elements;
717    // R = Rx(a)·Ry(b)·Rz(c) ⇒ m02 = sin b; a = atan2(−m12, m22);
718    // c = atan2(−m01, m00); gimbal lock at |sin b| → 1 pins c = 0.
719    let sb = m[2].clamp(-1.0, 1.0);
720    let (a, b, c) = if sb.abs() < 1.0 - 1e-9 {
721        (
722            (-m[6]).atan2(m[10]),
723            sb.asin(),
724            (-m[1]).atan2(m[0]),
725        )
726    } else {
727        (m[9].atan2(m[5]), sb.asin(), 0.0)
728    };
729    serde_json::json!({
730        "translate": [m[3], m[7], m[11]],
731        "rotateEulerDeg": [a.to_degrees(), b.to_degrees(), c.to_degrees()],
732    })
733}