Skip to main content

brep_render/
constraint_overlays.rs

1//! Assembly-constraint VIEWPORT overlays (build-spec §8.4) — the CONSTRAINT twin
2//! of [`crate::feature_dimensions`].
3//!
4//! Pure builders: the kernel's `assembly_overlay_json` rows (per-constraint world
5//! anchors / directions / status / measured value) + the `assembly_state_json`
6//! constraint list (for `inputParams` — expression detection + element refs) fold
7//! into [`ConstraintOverlay`] records, which bake into the SAME leader/arrow/arc
8//! triangle buffers the feature-dimension gizmo draws (via
9//! [`crate::feature_dimensions::leaders_buffers`] — nothing re-rolled, per the
10//! UI-consistency directive):
11//!
12//! * **distance** → a LINEAR dimension annotation — the silver rod + orange
13//!   cone arrow + orange origin sphere, GRABBABLE (drag edits
14//!   `inputParams.distance`, commit auto-solves). Plane-based pairings draw the
15//!   TRUE dimension perpendicular to the BASE face (the perpendicular-foot
16//!   construction on [`build_distance_annotation`], matching the kernel's
17//!   signed `d = (P_other − P_base)·n̂_base` convention); plane-less pairings
18//!   keep the plain anchor-to-anchor leader.
19//! * **angle** → an ANGULAR annotation (the screen-constant arc + orange sweep-end
20//!   handle + red dashed zero reference + green axis), GRABBABLE (drag edits
21//!   `inputParams.angle`).
22//! * **center** → two plain leaders drawn BY ROLE from the row's `groups`
23//!   (the kernel mapper's inferred `[width pair, tab]` element-index groups):
24//!   the width span between its two faces, and a leader from that span's
25//!   midpoint (which lies on the mid-plane) to the tab's anchor centroid; the
26//!   label sits at the width midpoint. Pick order never shapes the drawing.
27//! * everything else (coincident / parallel / perpendicular / concentric /
28//!   tangent / touch_align / fixed) → a plain anchor-to-anchor leader line +
29//!   label anchor only — never a handle.
30//!
31//! Dragging is DISABLED for a distance/angle whose param is a non-numeric
32//! EXPRESSION string (matches how feature dimensions treat expression-driven
33//! params); the engine consults [`ConstraintOverlay::draggable`].
34//!
35//! The interactive state machine (hit regions, drag preview, the
36//! `assembly_update_constraint_json` commit) lives in
37//! `engine_state/assembly_overlay.rs`; this module stays camera-free and pure so
38//! the geometry is unit-testable from canned JSON payloads.
39
40use serde_json::Value;
41
42use crate::feature_dimensions::{
43    append_plain_leader, leaders_buffers, FeatureDimAnnotation,
44};
45
46/// Which overlay family a constraint renders as.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum ConstraintOverlayKind {
49    /// A grabbable linear dimension arrow pair (`distance`).
50    Distance,
51    /// A grabbable angle arc (`angle`).
52    Angle,
53    /// A non-interactive leader + label (every other type).
54    Leader,
55}
56
57/// One constraint's overlay record: identity + status for the label, the
58/// resolved world geometry, and (for the dimensional kinds) the annotation the
59/// shared leader renderer draws + the drag machinery grabs.
60#[derive(Clone, Debug)]
61pub struct ConstraintOverlay {
62    /// The constraint id (`DIST3`, `ANGL2`, …) — the mutation-ABI key.
63    pub id: String,
64    /// The constraint `type` string (`distance`, `coincident`, …).
65    pub constraint_type: String,
66    /// The type's icon glyph (`brep_kernel::ConstraintTypeDef::icon`) — what
67    /// the viewport chip shows in place of the id. Empty for an unknown type,
68    /// in which case the chip falls back to the id.
69    pub icon: String,
70    /// The solve status (`satisfied` / `adjusted` / `error` / …) — drives the
71    /// label color via [`status_color`].
72    pub status: String,
73    /// The human status/solve message (label tooltip).
74    pub message: String,
75    /// The overlay family.
76    pub kind: ConstraintOverlayKind,
77    /// The resolved WORLD anchor points (one per selection; empty when the
78    /// kernel could not resolve the selections — label-less, geometry-less row).
79    pub anchors: Vec<[f64; 3]>,
80    /// The element ROLE groups a multi-element type inferred (`groups` on the
81    /// overlay row): index groups into `anchors`, role order — center's
82    /// `[width pair, tab]`. Empty for the pairing types, whose two anchors ARE
83    /// the drawing.
84    pub groups: Vec<Vec<usize>>,
85    /// Distance/Angle: the dimension annotation (reusing the feature-dim shape so
86    /// `leaders_buffers` renders it verbatim). `field_key` is the `inputParams`
87    /// key the drag edits (`distance` / `angle`). `None` for `Leader` rows and
88    /// for dimensional rows whose anchors did not resolve.
89    pub annotation: Option<FeatureDimAnnotation>,
90    /// The measured value the kernel evaluated (`value` in the overlay row), for
91    /// the label suffix. `None` for non-dimensional rows.
92    pub value: Option<f64>,
93    /// The value's unit (`"mm"` / `"deg"`), empty when `value` is `None`.
94    pub unit: String,
95    /// Whether the dimensional handle may be DRAGGED: true only for
96    /// Distance/Angle whose current param is numeric (or absent — a first-solve
97    /// initialized target). A non-numeric expression string disables the drag.
98    pub draggable: bool,
99    /// The constraint's `inputParams` (from the state list) — the drag commit
100    /// mutates a clone of this (so `elements` / flags ride along unchanged).
101    pub input_params: Value,
102    /// The referenced element names (`inputParams.elements`) — label hover
103    /// highlights these through the existing emphasis machinery.
104    pub elements: Vec<String>,
105}
106
107impl ConstraintOverlay {
108    /// The `inputParams` key a drag on this overlay edits (`distance` / `angle`).
109    pub fn field_key(&self) -> Option<&'static str> {
110        match self.kind {
111            ConstraintOverlayKind::Distance => Some("distance"),
112            ConstraintOverlayKind::Angle => Some("angle"),
113            ConstraintOverlayKind::Leader => None,
114        }
115    }
116
117    /// The world-space label anchor: a dimensional annotation's chip anchor (the
118    /// leader midpoint, or the angle arc's mid-sweep at the screen-constant
119    /// radius — camera-dependent via `world_per_pixel`); a role-grouped row's
120    /// first-group centroid (center: the width span's midpoint, on the
121    /// mid-plane); a leader row's anchor midpoint (or its single anchor).
122    /// `None` when nothing resolved.
123    pub fn label_anchor(&self, world_per_pixel: f64) -> Option<[f64; 3]> {
124        if let Some(annotation) = &self.annotation {
125            return Some(match self.kind {
126                ConstraintOverlayKind::Angle => {
127                    crate::feature_dimensions::angular_chip_anchor(annotation, world_per_pixel)
128                }
129                _ => annotation.midpoint(),
130            });
131        }
132        if let Some((span, _)) = self.role_leaders() {
133            return Some(co_mid(span.0, span.1));
134        }
135        match self.anchors.len() {
136            0 => None,
137            1 => Some(self.anchors[0]),
138            _ => {
139                let a = self.anchors[0];
140                let b = self.anchors[1];
141                Some([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5])
142            }
143        }
144    }
145
146    /// The role-drawn leaders of a grouped row (center): the WIDTH span
147    /// between the first group's two anchors, and the leader from that span's
148    /// midpoint to the second group's anchor centroid. `None` unless the row
149    /// carries two groups whose indexes all resolve to anchors (a pairing row,
150    /// or a stale `groups` against a shorter anchor list, draws the plain
151    /// leader instead).
152    fn role_leaders(&self) -> Option<(([f64; 3], [f64; 3]), ([f64; 3], [f64; 3]))> {
153        let [width, tab] = self.groups.as_slice() else {
154            return None;
155        };
156        let [w0, w1] = width.as_slice() else {
157            return None;
158        };
159        let (a, b) = (*self.anchors.get(*w0)?, *self.anchors.get(*w1)?);
160        if tab.is_empty() {
161            return None;
162        }
163        let mut centroid = [0.0f64; 3];
164        for &index in tab {
165            let p = self.anchors.get(index)?;
166            for k in 0..3 {
167                centroid[k] += p[k] / tab.len() as f64;
168            }
169        }
170        Some(((a, b), (co_mid(a, b), centroid)))
171    }
172
173    /// The label chip text: the type's icon, then `{value}{unit}` for
174    /// dimensional rows (`⟺ 5.25 mm`, `∠ 90°`); the bare icon otherwise. The
175    /// id no longer rides in the chip — the app's chip hover names it — so a
176    /// crowded assembly reads by picture, not by `DIST3`/`COIN9` codes. A row
177    /// whose type has no icon keeps the id in the icon's place.
178    pub fn label_text(&self) -> String {
179        let lead = if self.icon.is_empty() { self.id.as_str() } else { self.icon.as_str() };
180        match self.value {
181            Some(value) => {
182                let n = crate::formatting::compact_decimal(value, 2);
183                if self.unit == "deg" {
184                    format!("{lead} {n}\u{00b0}")
185                } else if self.unit.is_empty() {
186                    format!("{lead} {n}")
187                } else {
188                    format!("{lead} {n} {}", self.unit)
189                }
190            }
191            None => lead.to_string(),
192        }
193    }
194}
195
196// ---------------------------------------------------------------------------
197// Status → color
198// ---------------------------------------------------------------------------
199
200/// The requirements-doc §5 status → color vocabulary as display-sRGB `[r,g,b]`
201/// (0..1, hex/255 like the leader palette — the overlay shader writes ~directly).
202///
203/// Overlay-shader color (0..1 floats) for a constraint status — a thin view
204/// over the ONE canonical map in [`crate::assembly_status`] (the panel's row
205/// labels and the tree rollup consume the same table; unified at Wave-3
206/// integration so the vocabulary can never drift).
207pub fn status_color(status: &str) -> [f32; 3] {
208    let [r, g, b] = crate::assembly_status::status_color_rgb(status);
209    [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]
210}
211
212// ---------------------------------------------------------------------------
213// Builder
214// ---------------------------------------------------------------------------
215
216/// Build the overlay records from the kernel payloads: `overlay_rows` is the
217/// parsed `assembly_overlay_json` array; `state_constraints` is the parsed
218/// `assembly_state_json`'s `constraints` array (may be `Null` — the builder then
219/// has no `inputParams`, so dimensional rows fall back to draggable-with-empty
220/// params). Rows without resolved anchors yield status-only records (no
221/// geometry, no label anchor); a `fixed` constraint's single anchor yields a
222/// label anchor but no leader.
223pub fn build_constraint_overlays(
224    overlay_rows: &Value,
225    state_constraints: &Value,
226) -> Vec<ConstraintOverlay> {
227    let Some(rows) = overlay_rows.as_array() else {
228        return Vec::new();
229    };
230    rows.iter()
231        .filter_map(|row| build_row(row, state_constraints))
232        .collect()
233}
234
235fn build_row(row: &Value, state_constraints: &Value) -> Option<ConstraintOverlay> {
236    let id = row.get("id")?.as_str()?.to_string();
237    let constraint_type = row
238        .get("type")
239        .and_then(Value::as_str)
240        .unwrap_or("")
241        .to_string();
242    let icon = brep_kernel::constraint_type(&constraint_type)
243        .map(|def| def.icon.to_string())
244        .unwrap_or_default();
245    let status = row
246        .get("status")
247        .and_then(Value::as_str)
248        .unwrap_or("")
249        .to_string();
250    let message = row
251        .get("message")
252        .and_then(Value::as_str)
253        .unwrap_or("")
254        .to_string();
255    let anchors = read_points(row.get("anchors"));
256    let directions = read_dirs(row.get("directions"));
257    let geoms = read_strings(row.get("geoms"));
258    let groups = read_groups(row.get("groups"));
259    let value = row.get("value").and_then(Value::as_f64);
260    let unit = row
261        .get("unit")
262        .and_then(Value::as_str)
263        .unwrap_or("")
264        .to_string();
265
266    // The matching state entry's inputParams (expression check + elements).
267    let input_params = state_constraints
268        .as_array()
269        .and_then(|list| {
270            list.iter().find(|entry| {
271                entry
272                    .get("inputParams")
273                    .and_then(|p| p.get("id"))
274                    .and_then(Value::as_str)
275                    == Some(id.as_str())
276            })
277        })
278        .and_then(|entry| entry.get("inputParams"))
279        .cloned()
280        .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
281    let elements = crate::json_support::string_values(input_params.get("elements"))
282        .map(str::to_string)
283        .collect();
284
285    let kind = match constraint_type.as_str() {
286        "distance" => ConstraintOverlayKind::Distance,
287        "angle" => ConstraintOverlayKind::Angle,
288        _ => ConstraintOverlayKind::Leader,
289    };
290
291    // Dragging: only the dimensional kinds, and only while the param is NOT a
292    // non-numeric expression string (absent / number / plain numeric string are
293    // all draggable — matching the feature-dimension expression rule).
294    let draggable = match kind {
295        ConstraintOverlayKind::Leader => false,
296        ConstraintOverlayKind::Distance => param_allows_drag(&input_params, "distance"),
297        ConstraintOverlayKind::Angle => param_allows_drag(&input_params, "angle"),
298    };
299
300    let annotation = match kind {
301        ConstraintOverlayKind::Distance => {
302            build_distance_annotation(&anchors, &directions, &geoms, value)
303        }
304        ConstraintOverlayKind::Angle => build_angle_annotation(&anchors, &directions, value),
305        ConstraintOverlayKind::Leader => None,
306    };
307
308    Some(ConstraintOverlay {
309        id,
310        constraint_type,
311        icon,
312        status,
313        message,
314        kind,
315        anchors,
316        groups,
317        annotation,
318        value: match kind {
319            ConstraintOverlayKind::Leader => None,
320            _ => value,
321        },
322        unit,
323        draggable,
324        input_params,
325        elements,
326    })
327}
328
329/// Whether `params[key]` permits a value drag: absent (first-solve initialized),
330/// a JSON number, or a PLAIN numeric string — but NOT a non-numeric expression
331/// string (`"a + b"`), which stays authoritative and disables the handle.
332fn param_allows_drag(params: &Value, key: &str) -> bool {
333    match params.get(key) {
334        None | Some(Value::Null) => true,
335        Some(Value::Number(_)) => true,
336        Some(Value::String(text)) => text.trim().parse::<f64>().is_ok(),
337        _ => false,
338    }
339}
340
341/// The distance constraint's LINEAR annotation.
342///
343/// PLANE-BASED pairings (at least one element is tagged `"plane"` in the row's
344/// `geoms` — the BASE face, element 0 preferred, matching the kernel mapper's
345/// base choice) draw the TRUE dimension: the other element's anchor `P` is
346/// projected onto the base plane along its outward normal `n̂` — the
347/// perpendicular foot `F = P − s·n̂` with `s = (P − Q)·n̂` the SIGNED offset —
348/// and the arrow runs `F → P`. That segment is normal to the base face by
349/// construction and its length `|s|` IS the constrained distance, so the arrow
350/// shows the kernel's signed `d = (P_other − P_base)·n̂_base` convention
351/// verbatim (a negative `s` points behind the face). The base normal rides in
352/// the annotation's (linear-unused) `axis` field so the drag can measure
353/// signed offsets along it — including through the face into negatives, and
354/// even when `s = 0` collapses the segment to a point.
355///
356/// Plane-less pairings (lines/points — no side to be on) keep the plain
357/// `anchors[0] → anchors[1]` leader with the unsigned measured value and a
358/// zero `axis`. `None` unless both anchors resolved.
359fn build_distance_annotation(
360    anchors: &[[f64; 3]],
361    directions: &[Option<[f64; 3]>],
362    geoms: &[String],
363    value: Option<f64>,
364) -> Option<FeatureDimAnnotation> {
365    if anchors.len() < 2 {
366        return None;
367    }
368    // The base plane: the first element tagged "plane" with a usable normal.
369    let base = (0..2).find(|&i| {
370        geoms.get(i).map(String::as_str) == Some("plane")
371            && directions
372                .get(i)
373                .copied()
374                .flatten()
375                .is_some_and(|n| co_norm(n) > 1e-9)
376    });
377    if let Some(base) = base {
378        let n = directions[base].expect("base index checked above");
379        let len = co_norm(n);
380        let n = [n[0] / len, n[1] / len, n[2] / len];
381        let q = anchors[base];
382        let p = anchors[1 - base];
383        let s = co_dot(co_sub(p, q), n);
384        let foot = [p[0] - n[0] * s, p[1] - n[1] * s, p[2] - n[2] * s];
385        // The kernel's measured `value` equals `s` for both plane arms (same
386        // formula over the same anchors); prefer it for label consistency.
387        let mut annotation =
388            FeatureDimAnnotation::linear("distance", foot, p, value.unwrap_or(s), "D");
389        annotation.axis = n;
390        return Some(annotation);
391    }
392    let a = anchors[0];
393    let b = anchors[1];
394    let value = value.unwrap_or_else(|| co_norm(co_sub(b, a)));
395    Some(FeatureDimAnnotation::linear("distance", a, b, value, "D"))
396}
397
398/// The angle constraint's ANGULAR annotation. Geometry: the arc sweeps from
399/// direction `d0` toward `d1` about `axis = normalize(d0 × d1)` — the axis that
400/// makes rotating `d0` by the measured interior angle land exactly on `d1` — and
401/// is centered at the closest-approach midpoint of the two carrier lines
402/// (`anchors[i] + t·dᵢ`), the natural angle vertex; parallel/degenerate carriers
403/// fall back to the anchor midpoint, and a parallel/antiparallel pair (the 0°/
404/// 180° satisfied states — `d0 × d1 ≈ 0`) falls back to an arbitrary
405/// perpendicular axis via the `angular` ctor, which still renders and keeps the
406/// drag well-defined once the value moves off zero. `None` unless both anchors
407/// AND both directions resolved.
408fn build_angle_annotation(
409    anchors: &[[f64; 3]],
410    directions: &[Option<[f64; 3]>],
411    value: Option<f64>,
412) -> Option<FeatureDimAnnotation> {
413    if anchors.len() < 2 || directions.len() < 2 {
414        return None;
415    }
416    let d0 = directions[0]?;
417    let d1 = directions[1]?;
418    let axis = co_cross(d0, d1);
419    let center = carrier_closest_midpoint(anchors[0], d0, anchors[1], d1);
420    let value = value.unwrap_or(0.0).clamp(-360.0, 360.0);
421    Some(FeatureDimAnnotation::angular(
422        "angle", center, axis, d0, value, "A",
423    ))
424}
425
426/// The midpoint of the closest-approach segment between carrier lines
427/// `a + t·da` and `b + s·db`; the anchor midpoint when (near) parallel.
428fn carrier_closest_midpoint(a: [f64; 3], da: [f64; 3], b: [f64; 3], db: [f64; 3]) -> [f64; 3] {
429    let mid = |p: [f64; 3], q: [f64; 3]| {
430        [(p[0] + q[0]) * 0.5, (p[1] + q[1]) * 0.5, (p[2] + q[2]) * 0.5]
431    };
432    let da_n = co_norm(da);
433    let db_n = co_norm(db);
434    if da_n < 1e-9 || db_n < 1e-9 {
435        return mid(a, b);
436    }
437    let u = [da[0] / da_n, da[1] / da_n, da[2] / da_n];
438    let v = [db[0] / db_n, db[1] / db_n, db[2] / db_n];
439    let w0 = co_sub(a, b);
440    let b_uv = co_dot(u, v);
441    let denom = 1.0 - b_uv * b_uv;
442    if denom.abs() < 1e-9 {
443        return mid(a, b); // parallel carriers — no unique vertex
444    }
445    let d = co_dot(u, w0);
446    let e = co_dot(v, w0);
447    let t = (b_uv * e - d) / denom;
448    let s = (e - b_uv * d) / denom;
449    let p = [a[0] + u[0] * t, a[1] + u[1] * t, a[2] + u[2] * t];
450    let q = [b[0] + v[0] * s, b[1] + v[1] * s, b[2] + v[2] * s];
451    mid(p, q)
452}
453
454// ---------------------------------------------------------------------------
455// Buffers
456// ---------------------------------------------------------------------------
457
458/// Bake the whole overlay set into flat world-space triangle `(positions,
459/// colors)` buffers (the `tris` shape the overlay group consumes): the
460/// dimensional annotations through [`leaders_buffers`] (identical arrow/arc
461/// styling), the non-dimensional rows as plain silver leaders
462/// ([`append_plain_leader`]). Rows without geometry contribute nothing.
463pub fn constraint_overlay_buffers(
464    overlays: &[ConstraintOverlay],
465    world_per_pixel: f64,
466) -> (Vec<f32>, Vec<f32>) {
467    let annotations: Vec<FeatureDimAnnotation> = overlays
468        .iter()
469        .filter_map(|overlay| overlay.annotation.clone())
470        .collect();
471    let (mut positions, mut colors) = leaders_buffers(&annotations, world_per_pixel);
472    for overlay in overlays {
473        if overlay.kind != ConstraintOverlayKind::Leader {
474            continue;
475        }
476        if let Some((span, tab)) = overlay.role_leaders() {
477            // Role-drawn (center): the width span, then mid-plane → tab.
478            append_plain_leader(&mut positions, &mut colors, span.0, span.1, world_per_pixel);
479            append_plain_leader(&mut positions, &mut colors, tab.0, tab.1, world_per_pixel);
480        } else if overlay.anchors.len() >= 2 {
481            append_plain_leader(
482                &mut positions,
483                &mut colors,
484                overlay.anchors[0],
485                overlay.anchors[1],
486                world_per_pixel,
487            );
488        }
489    }
490    (positions, colors)
491}
492
493// ---------------------------------------------------------------------------
494// JSON + vec helpers (self-contained; `co_` prefixed like the fd_ family)
495// ---------------------------------------------------------------------------
496
497fn read_points(value: Option<&Value>) -> Vec<[f64; 3]> {
498    value
499        .and_then(Value::as_array)
500        .map(|list| list.iter().filter_map(read_point3).collect())
501        .unwrap_or_default()
502}
503
504/// Directions align index-wise with anchors; a JSON `null` (a point-like
505/// selection has no direction) stays `None`.
506fn read_dirs(value: Option<&Value>) -> Vec<Option<[f64; 3]>> {
507    value
508        .and_then(Value::as_array)
509        .map(|list| list.iter().map(read_point3).collect())
510        .unwrap_or_default()
511}
512
513/// The row's per-element `geoms` tags (aligned index-wise with anchors);
514/// empty when absent — a distance row then has no identifiable base plane and
515/// falls back to the plain anchor-to-anchor leader.
516fn read_strings(value: Option<&Value>) -> Vec<String> {
517    value
518        .and_then(Value::as_array)
519        .map(|list| {
520            list.iter()
521                .map(|v| v.as_str().unwrap_or("").to_string())
522                .collect()
523        })
524        .unwrap_or_default()
525}
526
527/// The row's role `groups` (index groups into `anchors`); empty when absent.
528fn read_groups(value: Option<&Value>) -> Vec<Vec<usize>> {
529    value
530        .and_then(Value::as_array)
531        .map(|groups| {
532            groups
533                .iter()
534                .map(|group| {
535                    group
536                        .as_array()
537                        .map(|list| {
538                            list.iter()
539                                .filter_map(Value::as_u64)
540                                .map(|index| index as usize)
541                                .collect()
542                        })
543                        .unwrap_or_default()
544                })
545                .collect()
546        })
547        .unwrap_or_default()
548}
549
550fn read_point3(value: &Value) -> Option<[f64; 3]> {
551    let list = value.as_array()?;
552    Some([
553        list.first()?.as_f64()?,
554        list.get(1)?.as_f64()?,
555        list.get(2)?.as_f64()?,
556    ])
557}
558
559fn co_mid(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
560    [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5]
561}
562
563fn co_sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
564    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
565}
566
567fn co_dot(a: [f64; 3], b: [f64; 3]) -> f64 {
568    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
569}
570
571fn co_norm(v: [f64; 3]) -> f64 {
572    co_dot(v, v).sqrt()
573}
574
575fn co_cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
576    [
577        a[1] * b[2] - a[2] * b[1],
578        a[2] * b[0] - a[0] * b[2],
579        a[0] * b[1] - a[1] * b[0],
580    ]
581}
582
583// BREP private tests: 03f60e4537c81872