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