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