Skip to main content

brep_kernel/feature_pipeline/pmi/
mod.rs

1//! PMI — Product and Manufacturing Information: the document's `pmi` block
2//! (views that own annotations), the annotation type table with its schemas
3//! and selection predicates, and the history tail that resolves every
4//! annotation against the just-built scene into a typed [`PmiReport`].
5//!
6//! # The `pmi` block
7//!
8//! `{ views: [{ id, name, camera?, display, annotations: [...] }], idCounter }`
9//! on the history request, round-tripped through the saved document and
10//! ABSENT on every document without PMI (so those files re-serialize
11//! byte-identically). A **view** is a named camera snapshot plus display
12//! state (text size, wireframe, hidden object names) plus an ordered list of
13//! annotations; a saved view maps to an AP242 `DRAUGHTING_MODEL` +
14//! `CAMERA_MODEL_D3` on export and is the unit a future drawing sheet places.
15//!
16//! An **annotation** is `{ type, enabled, inputParams, labelWorld? }`: its
17//! params follow a kernel schema exactly like a feature's or an assembly
18//! constraint's (`inputParams.id` is its id, `{SHORT}{n}` minted from the
19//! block's counter), so the app's dialog engine draws every annotation form
20//! from [`pmi_schema_catalogue`] and nothing is hand-written. Geometry is
21//! referenced by kernel entity NAME (faces / edges, `{solid}@x,y,z` vertices
22//! in WORLD coordinates, D/P frame names) — plain and component-owned
23//! geometry alike — so a rebuilt model re-resolves every value and a renamed
24//! or deleted entity puts the annotation into an error status without ever
25//! aborting the view or the run.
26//!
27//! Every drawn annotation has an **annotation plane**: its optional `plane`
28//! reference names a planar face or a reference plane the annotation lies
29//! IN (label projected onto it, drags stay in it, the dimension / extension
30//! lines and frames drawn in it, the AP242 `ANNOTATION_PLANE` placed on it);
31//! left empty, the annotation aligns to the view camera. A picked plane must
32//! be parallel to what it carries — a linear dimension's direction, an
33//! angle's or a circle's plane — or the annotation reports an error rather
34//! than a foreshortened value. Drawing sheets will show, per sheet view, the
35//! annotations whose plane is parallel to the view.
36//!
37//! # The tail
38//!
39//! `finish_history_run` runs after the wire-harness tail on every run and
40//! resolves EVERY view's annotations (a few name lookups each) into a
41//! [`PmiReport`]: per annotation the status, the display text (value +
42//! tolerance block, kernel-formatted so the viewport and the STEP file agree),
43//! the measured value, the resolved analytic geometry the presentation is laid
44//! out from ([`layout`]) and the label position (stored, or a default derived
45//! from the geometry). The report rides `HistoryResult.pmi` (never the JSON
46//! ABI) and the render crate's scene report across the runner seam. Nothing
47//! here touches the incremental feature cache: PMI is annotation state, not
48//! history, and a label drag never re-executes a feature.
49//!
50//! # Measurement contracts (kernel queries only)
51//!
52//! Every value is read from the exact BREP through `crate::assembly_resolve`
53//! (planes, carrier lines, circles, axes, points) — never from tessellation.
54//! Linear: point–point, point–line (perpendicular), line–line (carrier lines:
55//! parallel spacing or closest points), point–plane, parallel plane spacing,
56//! single straight edge length, optional X/Y/Z component. Radial: cylinder /
57//! sphere / circular edge radius from the face metadata. Angle: the two
58//! elements' directions folded to acute / obtuse / reflex, optionally
59//! reversed. Hole callout: the owning Hole feature's parameters. Datum letters
60//! are unique per part; a feature control frame's datum references must name
61//! defined datums.
62
63pub mod anchor;
64pub mod annotations;
65pub mod font;
66pub mod layout;
67pub mod resolve;
68mod plugin;
69pub use plugin::{PluginAnnotationOutput, PluginAnnotationReplay};
70
71use serde::{Deserialize, Serialize};
72use serde_json::Value;
73use std::collections::BTreeMap;
74
75use crate::feature_pipeline::{Env, HistoryRequest, SceneMap};
76
77pub use anchor::{
78    balloon_head, nearest_displayed_point, nearest_displayed_point_on_edge, nearest_point_on_edge, nearest_point_on_solid,
79    pose_apply, pose_unapply, BalloonAnchor, BalloonAnchorMode, HalfSpace, HeadScope, PmiPose, SolidPoses,
80};
81pub use annotations::{pmi_schema_catalogue, pmi_type, PmiTypeDef, PMI_TYPES};
82
83// ===========================================================================
84// The persisted block
85// ===========================================================================
86
87/// The document's `pmi` block.
88#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
89pub struct PmiState {
90    #[serde(default)]
91    pub views: Vec<PmiView>,
92    /// Mints every view AND annotation id (`VIEW{n}`, `DIM{n}`, …); monotonic,
93    /// never reused, re-seeded from the largest numeric suffix on load.
94    #[serde(default, rename = "idCounter")]
95    pub id_counter: u64,
96}
97
98/// A PMI view: a camera snapshot, display state and the annotations it owns.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct PmiView {
101    pub id: String,
102    #[serde(default)]
103    pub name: String,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub camera: Option<PmiCamera>,
106    #[serde(default)]
107    pub display: PmiDisplay,
108    #[serde(default)]
109    pub annotations: Vec<PmiAnnotation>,
110}
111
112/// The camera snapshot: eye / target / up / projection / viewport size — the
113/// render crate's `ViewCamera` minus near/far, which are a per-frame depth
114/// window and never persisted. Applying a snapshot under another viewport
115/// aspect refits the frustum, preserving the apparent size at the target.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct PmiCamera {
118    pub eye: [f64; 3],
119    pub target: [f64; 3],
120    pub up: [f64; 3],
121    pub projection: PmiProjection,
122    /// `[width, height]` of the viewport the snapshot was taken in (CSS px).
123    #[serde(default = "default_viewport")]
124    pub viewport: [f64; 2],
125}
126
127fn default_viewport() -> [f64; 2] {
128    [1280.0, 800.0]
129}
130
131impl PmiCamera {
132    /// The unit viewing direction (eye → target).
133    pub fn view_direction(&self) -> [f64; 3] {
134        let d = [
135            self.target[0] - self.eye[0],
136            self.target[1] - self.eye[1],
137            self.target[2] - self.eye[2],
138        ];
139        let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
140        if len < 1e-12 {
141            [0.0, 0.0, -1.0]
142        } else {
143            [d[0] / len, d[1] / len, d[2] / len]
144        }
145    }
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149#[serde(tag = "kind", rename_all = "camelCase")]
150pub enum PmiProjection {
151    Orthographic {
152        #[serde(rename = "halfHeight")]
153        half_height: f64,
154    },
155    Perspective {
156        #[serde(rename = "fovYDeg")]
157        fov_y_deg: f64,
158    },
159}
160
161/// Per-view display state.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct PmiDisplay {
164    /// Label text size in points, clamped to `[1, 288]`.
165    #[serde(default = "default_text_size", rename = "textSizePt")]
166    pub text_size_pt: f64,
167    #[serde(default)]
168    pub wireframe: bool,
169    /// Scene object NAMES hidden in this view (resolved to runtime visibility
170    /// on apply; a name that no longer exists is ignored).
171    #[serde(default)]
172    pub hidden: Vec<String>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub section: Option<PmiSection>,
175}
176
177/// A display-only half-space through `origin` plus an offset along the normal.
178///
179/// `origin` (and, for the `View` axis, `normal`) is frozen from the captured
180/// camera when the section is set, so re-capturing the camera does not move
181/// the cut. A section saved before they existed reads the camera instead.
182/// The `Plane` axis takes both from `plane`, a planar face or reference plane
183/// the run resolves each time (see [`PmiViewReport::section_plane`]).
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185pub struct PmiSection {
186    pub axis: String,
187    pub offset: f64,
188    #[serde(default)]
189    pub flip: bool,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub origin: Option<[f64; 3]>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub normal: Option<[f64; 3]>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub plane: Option<String>,
196    /// Solids and components the cut leaves whole (shafts, fasteners): a
197    /// solid's scene name, or a component id standing for all its solids.
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub exclude: Vec<String>,
200    /// Further cutting planes. Material is removed only where EVERY plane
201    /// removes it: one more plane makes a half section (a quarter cut away),
202    /// two more a corner cut away. Each is an axis through the section's
203    /// origin, offset along its normal.
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub planes: Vec<PmiSectionPlane>,
206}
207
208/// One further plane of a [`PmiSection`]: `X`, `Y`, `Z` or `View` (the
209/// captured camera's direction, frozen in `normal` when set), `offset` along
210/// it from the section's origin, `flip` keeping the other side.
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212pub struct PmiSectionPlane {
213    pub axis: String,
214    #[serde(default)]
215    pub offset: f64,
216    #[serde(default)]
217    pub flip: bool,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub normal: Option<[f64; 3]>,
220}
221
222impl PmiSectionPlane {
223    /// The unflipped normal: the axis's, else (`View`) the frozen one, else
224    /// the camera's view direction. `None` for an unknown axis.
225    pub fn axis_normal(&self, camera: Option<&PmiCamera>) -> Option<[f64; 3]> {
226        match self.axis.as_str() {
227            "X" => Some([1., 0., 0.]),
228            "Y" => Some([0., 1., 0.]),
229            "Z" => Some([0., 0., 1.]),
230            "View" => self.normal.or_else(|| camera.map(PmiCamera::view_direction)),
231            _ => None,
232        }
233    }
234}
235impl PmiSection {
236    /// Whether the solid named `solid` is left uncut: it is listed, or it
237    /// belongs to a listed component (`ACOMP1` covers `ACOMP1:Part` and a
238    /// nested `ACOMP1:ACOMP2:Part`).
239    pub fn excludes(&self, solid: &str) -> bool {
240        self.exclude.iter().any(|entry| {
241            let entry = entry.trim();
242            !entry.is_empty() && (solid == entry || solid.strip_prefix(entry).is_some_and(|rest| rest.starts_with(':')))
243        })
244    }
245
246    /// The unflipped normal: the axis's, else (`View`) the frozen one, else
247    /// the camera's view direction.
248    pub fn axis_normal(&self, camera: Option<&PmiCamera>) -> Option<[f64; 3]> {
249        match self.axis.as_str() {
250            "X" => Some([1., 0., 0.]),
251            "Y" => Some([0., 1., 0.]),
252            "Z" => Some([0., 0., 1.]),
253            "Plane" => self.normal,
254            _ => self.normal.or_else(|| camera.map(PmiCamera::view_direction)),
255        }
256    }
257
258    /// Fill a missing `origin` / `View` normal from `camera`, so the plane
259    /// stops depending on it.
260    pub fn freeze(&mut self, camera: Option<&PmiCamera>) {
261        if self.axis == "Plane" {
262            // Resolved from `plane` on every run, never from the camera.
263            self.origin = None;
264            self.normal = None;
265            return;
266        }
267        self.plane = None;
268        let Some(camera) = camera else { return };
269        for plane in &mut self.planes {
270            if plane.axis == "View" {
271                plane.normal.get_or_insert(camera.view_direction());
272            } else {
273                plane.normal = None;
274            }
275        }
276        self.origin.get_or_insert(camera.target);
277        if self.axis == "View" {
278            self.normal.get_or_insert(camera.view_direction());
279        } else {
280            self.normal = None;
281        }
282    }
283}
284impl PmiView {
285    pub fn section_plane(&self) -> Option<([f64; 3], [f64; 3])> {
286        let section = self.display.section.as_ref()?;
287        let camera = self.camera.as_ref();
288        let mut normal = section.axis_normal(camera)?;
289        let origin = section.origin.or_else(|| camera.filter(|_| section.axis != "Plane").map(|c| c.target))?;
290        let point = std::array::from_fn(|i| origin[i] + normal[i] * section.offset);
291        if section.flip {
292            normal = normal.map(|v| -v);
293        }
294        Some((point, normal))
295    }
296
297    /// [`Self::section_plane`], taking a `Plane` section's plane from the
298    /// view's last report (the document stores only the reference's name).
299    pub fn section_plane_in(&self, report: Option<&PmiViewReport>) -> Option<([f64; 3], [f64; 3])> {
300        match self.display.section.as_ref() {
301            Some(section) if section.axis == "Plane" => report.and_then(|report| report.section_plane),
302            _ => self.section_plane(),
303        }
304    }
305
306    /// Every cutting plane of the view's section, `(point, kept-side
307    /// normal)`, the first being [`Self::section_plane_in`]'s: empty without
308    /// a section (or when its first plane does not resolve). A further plane
309    /// goes through the section's origin (else the camera target, else the
310    /// first plane's point) plus its offset; an unknown axis is skipped.
311    pub fn section_planes_in(&self, report: Option<&PmiViewReport>) -> Vec<([f64; 3], [f64; 3])> {
312        self.section_plane_in(report).map_or_else(Vec::new, |first| self.with_further_planes(first))
313    }
314
315    /// [`Self::section_planes_in`] from [`Self::section_plane`] (a `Plane`
316    /// section resolved into the view itself, as the run does).
317    pub fn section_planes(&self) -> Vec<([f64; 3], [f64; 3])> {
318        self.section_plane().map_or_else(Vec::new, |first| self.with_further_planes(first))
319    }
320
321    fn with_further_planes(&self, first: ([f64; 3], [f64; 3])) -> Vec<([f64; 3], [f64; 3])> {
322        let mut planes = vec![first];
323        let Some(section) = self.display.section.as_ref() else { return planes };
324        let camera = self.camera.as_ref();
325        let origin = section.origin.or_else(|| camera.filter(|_| section.axis != "Plane").map(|c| c.target)).unwrap_or(first.0);
326        for plane in &section.planes {
327            let Some(mut normal) = plane.axis_normal(camera) else { continue };
328            let point = std::array::from_fn(|i| origin[i] + normal[i] * plane.offset);
329            if plane.flip {
330                normal = normal.map(|v| -v);
331            }
332            planes.push((point, normal));
333        }
334        planes
335    }
336}
337
338fn default_text_size() -> f64 {
339    12.0
340}
341
342impl Default for PmiDisplay {
343    fn default() -> Self {
344        Self {
345            text_size_pt: default_text_size(),
346            wireframe: false,
347            hidden: Vec::new(),
348            section: None,
349        }
350    }
351}
352
353/// The text size clamp: `[1, 288]` points.
354pub fn clamp_text_size(size: f64) -> f64 {
355    if !size.is_finite() {
356        return default_text_size();
357    }
358    size.clamp(1.0, 288.0)
359}
360
361/// One annotation: schema-driven params keyed by its `type`.
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363pub struct PmiAnnotation {
364    #[serde(rename = "type")]
365    pub kind: String,
366    #[serde(default = "default_true")]
367    pub enabled: bool,
368    #[serde(default, rename = "inputParams")]
369    pub params: Value,
370    #[serde(default, rename = "persistentData", skip_serializing_if = "Value::is_null")]
371    pub persistent_data: Value,
372    /// Last accepted callback transition. Replays use its prior input, never saved geometry.
373    #[serde(default, rename = "pluginReplay", skip_serializing_if = "Option::is_none")]
374    pub plugin_replay: Option<PluginAnnotationReplay>,
375    /// The draggable label's world anchor. Absent until the user moves it —
376    /// the report then carries a default derived from the geometry.
377    #[serde(default, rename = "labelWorld", skip_serializing_if = "Option::is_none")]
378    pub label_world: Option<[f64; 3]>,
379}
380
381fn is_false(value: &bool) -> bool { !value }
382
383fn default_true() -> bool {
384    true
385}
386
387impl PmiAnnotation {
388    /// `inputParams.id` (empty when absent).
389    pub fn id(&self) -> &str {
390        self.params
391            .get("id")
392            .and_then(Value::as_str)
393            .unwrap_or("")
394    }
395
396    /// A string param, trimmed (`""` when absent or not a string).
397    pub fn text(&self, key: &str) -> &str {
398        self.params
399            .get(key)
400            .and_then(Value::as_str)
401            .map(str::trim)
402            .unwrap_or("")
403    }
404
405    /// A boolean param (`false` when absent).
406    pub fn flag(&self, key: &str) -> bool {
407        self.params
408            .get(key)
409            .and_then(Value::as_bool)
410            .unwrap_or(false)
411    }
412
413    /// A numeric param: a JSON number, or a string evaluated as an expression
414    /// against the document's variable sheet. `default` when absent / empty.
415    pub fn number(&self, key: &str, env: &Env, default: f64) -> Result<f64, String> {
416        match self.params.get(key) {
417            None | Some(Value::Null) => Ok(default),
418            Some(Value::Number(number)) => Ok(number.as_f64().unwrap_or(default)),
419            Some(Value::Bool(flag)) => Ok(if *flag { 1.0 } else { 0.0 }),
420            Some(Value::String(source)) => {
421                let source = source.trim();
422                if source.is_empty() {
423                    return Ok(default);
424                }
425                env.eval(source)
426                    .map_err(|error| format!("{key}: {error}"))
427            }
428            Some(other) => Err(format!("{key}: expected a number, got {other}")),
429        }
430    }
431
432    /// The `plane` reference (a planar face / reference plane name), when
433    /// the annotation is laid out in a picked plane rather than the view.
434    pub fn plane_ref(&self) -> Option<&str> {
435        let name = self.text("plane").trim();
436        (!name.is_empty()).then_some(name)
437    }
438
439    /// A reference param as a name list: a string, or an array of strings
440    /// (empty strings dropped).
441    pub fn references(&self, key: &str) -> Vec<String> {
442        match self.params.get(key) {
443            Some(Value::String(name)) => {
444                let name = name.trim();
445                if name.is_empty() {
446                    Vec::new()
447                } else {
448                    vec![name.to_string()]
449                }
450            }
451            Some(Value::Array(items)) => items
452                .iter()
453                .filter_map(Value::as_str)
454                .map(str::trim)
455                .filter(|name| !name.is_empty())
456                .map(String::from)
457                .collect(),
458            _ => Vec::new(),
459        }
460    }
461}
462
463impl PmiState {
464    /// Mint the next id with `prefix` (`VIEW` → `VIEW3`). The counter is
465    /// re-seeded first from the largest numeric suffix already in the block,
466    /// so an edited or merged document never hands out a colliding id.
467    pub fn next_id(&mut self, prefix: &str) -> String {
468        let seen = self.max_numeric_suffix();
469        if seen > self.id_counter {
470            self.id_counter = seen;
471        }
472        loop {
473            self.id_counter += 1;
474            let candidate = format!("{prefix}{}", self.id_counter);
475            if self.find_view(&candidate).is_none() && self.find_annotation(&candidate).is_none() {
476                return candidate;
477            }
478        }
479    }
480
481    fn max_numeric_suffix(&self) -> u64 {
482        let mut best = 0u64;
483        let mut consider = |id: &str| {
484            let digits = id
485                .bytes()
486                .rev()
487                .take_while(u8::is_ascii_digit)
488                .count();
489            if digits > 0 {
490                if let Ok(value) = id[id.len() - digits..].parse::<u64>() {
491                    best = best.max(value);
492                }
493            }
494        };
495        for view in &self.views {
496            consider(&view.id);
497            for annotation in &view.annotations {
498                consider(annotation.id());
499            }
500        }
501        best
502    }
503
504    pub fn find_view(&self, id: &str) -> Option<&PmiView> {
505        self.views.iter().find(|view| view.id == id)
506    }
507
508    pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PmiView> {
509        self.views.iter_mut().find(|view| view.id == id)
510    }
511
512    /// The annotation with `id` and its owning view.
513    pub fn find_annotation(&self, id: &str) -> Option<(&PmiView, &PmiAnnotation)> {
514        self.views.iter().find_map(|view| {
515            view.annotations
516                .iter()
517                .find(|annotation| annotation.id() == id)
518                .map(|annotation| (view, annotation))
519        })
520    }
521
522    /// The owning view id and index of annotation `id`.
523    pub fn locate_annotation(&self, id: &str) -> Option<(usize, usize)> {
524        self.views.iter().enumerate().find_map(|(view_index, view)| {
525            view.annotations
526                .iter()
527                .position(|annotation| annotation.id() == id)
528                .map(|index| (view_index, index))
529        })
530    }
531
532    pub fn find_annotation_mut(&mut self, id: &str) -> Option<&mut PmiAnnotation> {
533        self.views.iter_mut().find_map(|view| {
534            view.annotations
535                .iter_mut()
536                .find(|annotation| annotation.id() == id)
537        })
538    }
539
540    /// Every datum letter defined in the part (any view), in definition
541    /// order, with the defining annotation's id — the part-level datum
542    /// registry a feature control frame validates its references against.
543    pub fn datum_letters(&self) -> Vec<(String, String)> {
544        let mut out = Vec::new();
545        for view in &self.views {
546            for annotation in &view.annotations {
547                if annotation.kind == annotations::datum::DEF.type_id {
548                    let letter = annotation.text("letter").to_uppercase();
549                    if !letter.is_empty() {
550                        out.push((letter, annotation.id().to_string()));
551                    }
552                }
553            }
554        }
555        out
556    }
557
558    /// The next unused datum letter: A–Z skipping I, O and Q (ASME Y14.5),
559    /// then AA, AB, … (`None` only past ZZ).
560    pub fn next_datum_letter(&self) -> Option<String> {
561        let used: Vec<String> = self.datum_letters().into_iter().map(|(l, _)| l).collect();
562        let alphabet: Vec<char> = ('A'..='Z').filter(|c| !matches!(c, 'I' | 'O' | 'Q')).collect();
563        for letter in &alphabet {
564            let candidate = letter.to_string();
565            if !used.contains(&candidate) {
566                return Some(candidate);
567            }
568        }
569        for first in &alphabet {
570            for second in &alphabet {
571                let candidate = format!("{first}{second}");
572                if !used.contains(&candidate) {
573                    return Some(candidate);
574                }
575            }
576        }
577        None
578    }
579
580    /// Whether the block carries anything worth persisting.
581    pub fn is_empty(&self) -> bool {
582        self.views.is_empty() && self.id_counter == 0
583    }
584}
585
586// ===========================================================================
587// The resolved report
588// ===========================================================================
589
590/// The tail's resolution of every view's annotations.
591#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
592pub struct PmiReport {
593    pub views: Vec<PmiViewReport>,
594}
595
596impl PmiReport {
597    pub fn view(&self, id: &str) -> Option<&PmiViewReport> {
598        self.views.iter().find(|view| view.id == id)
599    }
600
601    pub fn annotation(&self, id: &str) -> Option<&PmiAnnotationReport> {
602        self.views
603            .iter()
604            .find_map(|view| view.annotations.iter().find(|a| a.id == id))
605    }
606
607    pub fn annotation_mut(&mut self, id: &str) -> Option<&mut PmiAnnotationReport> {
608        self.views
609            .iter_mut()
610            .find_map(|view| view.annotations.iter_mut().find(|a| a.id == id))
611    }
612}
613
614#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
615pub struct PmiViewReport {
616    pub id: String,
617    pub annotations: Vec<PmiAnnotationReport>,
618    /// A `Plane` section's cutting plane `(point, kept-side normal)`, resolved
619    /// from its reference by this run.
620    #[serde(default, rename = "sectionPlane", skip_serializing_if = "Option::is_none")]
621    pub section_plane: Option<([f64; 3], [f64; 3])>,
622    /// Why a `Plane` section's reference did not resolve (no cut is shown).
623    #[serde(default, rename = "sectionError", skip_serializing_if = "String::is_empty")]
624    pub section_error: String,
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
628#[serde(rename_all = "kebab-case")]
629pub enum PmiStatus {
630    /// Resolved: `text` / `value` / `geometry` are live.
631    Ok,
632    /// An unresolved anchor, a dangling datum reference, an unsupported
633    /// pairing …: `message` says which. The annotation stays listed.
634    Error,
635}
636
637/// One resolved annotation.
638#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
639pub struct PmiAnnotationReport {
640    /// Successful provider output only; errors must preserve saved input data.
641    #[serde(default, rename = "persistentData", skip_serializing_if = "Option::is_none")]
642    pub persistent_data: Option<Value>,
643    #[serde(default, rename = "pluginReplay", skip_serializing_if = "Option::is_none")]
644    pub plugin_replay: Option<PluginAnnotationReplay>,
645    pub id: String,
646    #[serde(rename = "type")]
647    pub kind: String,
648    pub enabled: bool,
649    pub status: PmiStatus,
650    #[serde(default)]
651    pub message: String,
652    /// The display text (value + tolerance block, note / leader text, hole
653    /// callout, datum letter, FCF cells joined).
654    #[serde(default)]
655    pub text: String,
656    /// The measured value (length in model units, angle in degrees).
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub value: Option<f64>,
659    /// `"mm"` / `"deg"` / `""`.
660    #[serde(default)]
661    pub unit: String,
662    /// The scene entity names the annotation resolved (for hover
663    /// highlighting and the STEP shape aspects), in reference order.
664    #[serde(default)]
665    pub references: Vec<String>,
666    /// The label anchor: the stored `labelWorld`, else the geometry's default
667    /// — projected onto the annotation plane when one is picked.
668    #[serde(rename = "labelWorld")]
669    pub label_world: [f64; 3],
670    /// The picked annotation plane; `None` aligns to the view camera.
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub plane: Option<PmiPlane>,
673    pub geometry: PmiGeometry,
674}
675
676/// A resolved annotation plane: the plane the annotation lies in (its
677/// `plane` reference — a planar face or a reference plane), `normal`
678/// oriented toward the view's captured camera, `x_axis` the in-plane text
679/// direction (the camera's right projected into the plane). The viewport
680/// overlay and the AP242 `ANNOTATION_PLANE` both lay the annotation out in
681/// this frame.
682#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
683pub struct PmiPlane {
684    pub origin: [f64; 3],
685    pub normal: [f64; 3],
686    #[serde(rename = "xAxis")]
687    pub x_axis: [f64; 3],
688}
689
690impl PmiPlane {
691    /// `point` projected onto the plane along its normal.
692    pub fn project(&self, point: [f64; 3]) -> [f64; 3] {
693        let n = self.normal;
694        let d = (point[0] - self.origin[0]) * n[0] + (point[1] - self.origin[1]) * n[1] + (point[2] - self.origin[2]) * n[2];
695        [point[0] - n[0] * d, point[1] - n[1] * d, point[2] - n[2] * d]
696    }
697
698    /// The in-plane up direction (`normal × x_axis`).
699    pub fn y_axis(&self) -> [f64; 3] {
700        let n = self.normal;
701        let x = self.x_axis;
702        [n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0]]
703    }
704
705    /// The ray `origin + t·dir` hit on the plane, if not parallel.
706    pub fn hit(&self, origin: [f64; 3], dir: [f64; 3]) -> Option<[f64; 3]> {
707        let n = self.normal;
708        let denominator = dir[0] * n[0] + dir[1] * n[1] + dir[2] * n[2];
709        if denominator.abs() < 1e-12 {
710            return None;
711        }
712        let diff = [self.origin[0] - origin[0], self.origin[1] - origin[1], self.origin[2] - origin[2]];
713        let t = (diff[0] * n[0] + diff[1] * n[1] + diff[2] * n[2]) / denominator;
714        Some([origin[0] + dir[0] * t, origin[1] + dir[1] * t, origin[2] + dir[2] * t])
715    }
716}
717
718/// The analytic geometry an annotation resolved to — what the presentation
719/// ([`layout`]) and the STEP semantic export are built from.
720#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
721#[serde(tag = "kind", rename_all = "camelCase")]
722pub enum PmiGeometry {
723    None,
724    /// Measured from `a` to `b` (world). `component` names the X/Y/Z axis
725    /// when the dimension is an aligned component, else `None`.
726    Linear {
727        a: [f64; 3],
728        b: [f64; 3],
729        #[serde(default, skip_serializing_if = "Option::is_none")]
730        component: Option<char>,
731    },
732    Radial {
733        center: [f64; 3],
734        axis: [f64; 3],
735        radius: f64,
736        diameter: bool,
737        sphere: bool,
738    },
739    /// The arc sweeps `degrees` from `dir_a` about `axis` (right-handed) at
740    /// `vertex`.
741    Angular {
742        vertex: [f64; 3],
743        #[serde(rename = "dirA")]
744        dir_a: [f64; 3],
745        #[serde(rename = "dirB")]
746        dir_b: [f64; 3],
747        axis: [f64; 3],
748        degrees: f64,
749    },
750    Leader {
751        targets: Vec<[f64; 3]>,
752        dot: bool,
753        #[serde(default, skip_serializing_if = "is_false")]
754        balloon: bool,
755        /// A balloon's head derivation recipe (`None` on a plain leader): the
756        /// viewport re-derives `targets[0]` from a dragged bubble with it.
757        #[serde(default, skip_serializing_if = "Option::is_none")]
758        anchor: Option<BalloonAnchor>,
759    },
760    Note {
761        position: [f64; 3],
762    },
763    Hole {
764        anchor: [f64; 3],
765        normal: [f64; 3],
766    },
767    /// Display-only: pose `solids` by the delta while the view is active.
768    Explode {
769        solids: Vec<String>,
770        translate: [f64; 3],
771        #[serde(rename = "rotateDeg")]
772        rotate_deg: [f64; 3],
773        scale: [f64; 3],
774        /// The rotation / scale pivot: the targets' aggregate bbox center.
775        center: [f64; 3],
776        trace: bool,
777    },
778    Datum {
779        anchor: [f64; 3],
780        normal: [f64; 3],
781        letter: String,
782    },
783    Fcf {
784        anchor: [f64; 3],
785        normal: [f64; 3],
786        frame: FcfFrame,
787    },
788}
789
790/// The cells of a feature control frame.
791#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
792pub struct FcfFrame {
793    /// The characteristic id (`flatness`, `position`, …).
794    pub characteristic: String,
795    /// Its Unicode symbol (the glyph key).
796    pub symbol: String,
797    /// The tolerance cell text (`⌀0.1 Ⓜ`).
798    pub zone: String,
799    /// The datum reference cells (`A`, `B Ⓜ`).
800    pub datums: Vec<String>,
801}
802
803// ===========================================================================
804// Value formatting (shared by the viewport and the STEP presentation)
805// ===========================================================================
806
807/// The tolerance block of a dimension: `tolMode` none / symmetric / deviation
808/// / limits with `tolUpper` / `tolLower`.
809#[derive(Debug, Clone, Copy, PartialEq)]
810pub struct ToleranceBlock {
811    pub mode: ToleranceMode,
812    pub upper: f64,
813    pub lower: f64,
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817pub enum ToleranceMode {
818    None,
819    Symmetric,
820    Deviation,
821    Limits,
822}
823
824impl ToleranceMode {
825    pub fn parse(text: &str) -> Self {
826        match text.trim().to_ascii_lowercase().as_str() {
827            "symmetric" => ToleranceMode::Symmetric,
828            "deviation" => ToleranceMode::Deviation,
829            "limits" => ToleranceMode::Limits,
830            _ => ToleranceMode::None,
831        }
832    }
833
834    pub fn as_str(self) -> &'static str {
835        match self {
836            ToleranceMode::None => "none",
837            ToleranceMode::Symmetric => "symmetric",
838            ToleranceMode::Deviation => "deviation",
839            ToleranceMode::Limits => "limits",
840        }
841    }
842}
843
844impl ToleranceBlock {
845    /// Read the block off an annotation's params.
846    pub fn read(annotation: &PmiAnnotation, env: &Env) -> Result<Self, String> {
847        Ok(Self {
848            mode: ToleranceMode::parse(annotation.text("tolMode")),
849            upper: annotation.number("tolUpper", env, 0.0)?.abs(),
850            lower: annotation.number("tolLower", env, 0.0)?.abs(),
851        })
852    }
853
854    /// The signed bounds as offsets from the nominal (`lower ≤ 0 ≤ upper`),
855    /// `None` when the mode carries no tolerance.
856    pub fn bounds(&self) -> Option<(f64, f64)> {
857        match self.mode {
858            ToleranceMode::None => None,
859            ToleranceMode::Symmetric => Some((-self.upper, self.upper)),
860            ToleranceMode::Deviation | ToleranceMode::Limits => Some((-self.lower, self.upper)),
861        }
862    }
863}
864
865/// `value` with `decimals` places (`0..=8`), no exponent.
866pub fn format_number(value: f64, decimals: usize) -> String {
867    let decimals = decimals.min(8);
868    let text = format!("{:.*}", decimals, value);
869    // `-0.000` reads as a sign error on a dimension.
870    if text.starts_with('-') && text[1..].bytes().all(|b| b == b'0' || b == b'.') {
871        text[1..].to_string()
872    } else {
873        text
874    }
875}
876
877/// The dimension text: `prefix` (`⌀` / `R` / `""`) + the nominal (+ the
878/// tolerance block) + `suffix` (`°` / `""`); a reference dimension is
879/// parenthesized and shows no tolerance.
880pub fn format_dimension(
881    value: f64,
882    decimals: usize,
883    tolerance: &ToleranceBlock,
884    is_reference: bool,
885    prefix: &str,
886    suffix: &str,
887) -> String {
888    let nominal = format!("{prefix}{}{suffix}", format_number(value, decimals));
889    if is_reference {
890        return format!("({nominal})");
891    }
892    match tolerance.mode {
893        ToleranceMode::None => nominal,
894        ToleranceMode::Symmetric => format!(
895            "{nominal} \u{00B1}{}{suffix}",
896            format_number(tolerance.upper, decimals)
897        ),
898        ToleranceMode::Deviation => format!(
899            "{nominal} +{}{suffix}/\u{2212}{}{suffix}",
900            format_number(tolerance.upper, decimals),
901            format_number(tolerance.lower, decimals)
902        ),
903        ToleranceMode::Limits => format!(
904            "{prefix}{}{suffix} / {prefix}{}{suffix}",
905            format_number(value + tolerance.upper, decimals),
906            format_number(value - tolerance.lower, decimals)
907        ),
908    }
909}
910
911// ===========================================================================
912// The history tail
913// ===========================================================================
914
915/// What the annotation resolvers read: the live scene, the request (hole
916/// features for callouts, the datum registry) and the expression sheet.
917pub struct PmiContext<'a> {
918    pub scene: &'a SceneMap,
919    pub request: &'a HistoryRequest,
920    pub env: &'a Env,
921    /// The PMI block being resolved, its `Plane` sections already resolved.
922    pub state: &'a PmiState,
923    /// The part-level datum registry (letter → annotation id).
924    pub datums: BTreeMap<String, String>,
925}
926
927/// The outcome of one annotation's resolver.
928pub struct Resolved {
929    pub text: String,
930    pub value: Option<f64>,
931    pub unit: &'static str,
932    pub references: Vec<String>,
933    pub geometry: PmiGeometry,
934    /// The default label anchor when the annotation stores none.
935    pub default_label: [f64; 3],
936}
937
938/// Resolve every view's annotations against `scene`. `None` when the request
939/// carries no `pmi` block (a document without PMI reports nothing).
940pub(crate) fn finish_history_run(
941    request: &HistoryRequest,
942    scene: &SceneMap,
943    env: &Env,
944) -> Option<PmiReport> {
945    let state = request.pmi.as_ref()?;
946    Some(resolve_state(state, scene, request, env))
947}
948
949/// Resolve `state` (the tail's body, callable on any scene).
950pub fn resolve_state(
951    state: &PmiState,
952    scene: &SceneMap,
953    request: &HistoryRequest,
954    env: &Env,
955) -> PmiReport {
956    // The datum registry: first definition of a letter wins; a duplicate is
957    // reported on the later annotation by the datum resolver.
958    let mut datums: BTreeMap<String, String> = BTreeMap::new();
959    for (letter, id) in state.datum_letters() {
960        datums.entry(letter).or_insert(id);
961    }
962    // A section on a picked plane resolves against this scene; the
963    // annotations (balloons included) then see it as a fixed plane.
964    let mut state = state.clone();
965    let mut section_errors: BTreeMap<String, String> = BTreeMap::new();
966    for view in &mut state.views {
967        if let Err(error) = resolve_section_plane(view, scene) {
968            section_errors.insert(view.id.clone(), error);
969        }
970    }
971    let state = &state;
972    let context = PmiContext {
973        scene,
974        request,
975        env,
976        state,
977        datums,
978    };
979    PmiReport {
980        views: state
981            .views
982            .iter()
983            .map(|view| {
984                let section = section_half_space(view);
985                let exempt = |reference: &str| {
986                    let section = view.display.section.as_ref();
987                    section.is_some_and(|section| reference_solid(scene, reference).is_some_and(|solid| section.excludes(&solid)))
988                };
989                PmiViewReport {
990                    section_plane: view.display.section.as_ref().filter(|s| s.axis == "Plane").and_then(|_| view.section_plane()),
991                    section_error: section_errors.remove(&view.id).unwrap_or_default(),
992                    id: view.id.clone(),
993                    annotations: view
994                        .annotations
995                        .iter()
996                        .map(|annotation| {
997                            let report = resolve_annotation(annotation, &context, view.camera.as_ref());
998                            match &section {
999                                Some(section) => sectioned_away(report, section, &exempt),
1000                                None => report,
1001                            }
1002                        })
1003                        .collect(),
1004                }
1005            })
1006            .collect(),
1007    }
1008}
1009
1010/// Fill a `Plane` section's origin and normal from its reference: a planar
1011/// face or a reference plane (datum or sketch plane) of `scene`.
1012fn resolve_section_plane(view: &mut PmiView, scene: &SceneMap) -> Result<(), String> {
1013    let Some(section) = view.display.section.as_mut().filter(|s| s.axis == "Plane") else {
1014        return Ok(());
1015    };
1016    let name = section.plane.clone().unwrap_or_default();
1017    if name.trim().is_empty() {
1018        return Err("pick a planar face or a datum plane to cut on".into());
1019    }
1020    match resolve::resolve_reference(scene, &name) {
1021        Ok(crate::SelectionGeometry::Plane { origin, normal }) => {
1022            section.origin = Some(resolve::a3(origin));
1023            section.normal = Some(resolve::a3(normal));
1024            Ok(())
1025        }
1026        Ok(_) => Err(format!("section plane '{name}' is not planar: pick a planar face or a datum plane")),
1027        Err(error) => Err(format!("section plane '{name}': {error}")),
1028    }
1029}
1030
1031/// The half-space a sectioned view keeps, for checking where its
1032/// annotations land. `None` without a section, and for a view that explodes
1033/// anything: its annotations measure the modeling pose while the cut is made
1034/// on the exploded display, so the two cannot be compared.
1035fn section_half_space(view: &PmiView) -> Option<anchor::HalfSpace> {
1036    if view.annotations.iter().any(|a| a.enabled && a.kind == "explode") {
1037        return None;
1038    }
1039    anchor::HalfSpace::of(&view.section_planes())
1040}
1041
1042/// A resolved annotation whose measured points lie on the side the view's
1043/// section removes would be drawn on nothing; it is reported as an error,
1044/// its chip kept where its label is, the way a balloon on a removed part is.
1045/// Balloons check their own anchors, radial and angular annotations measure
1046/// through their centre or vertex (often off the part), and notes float.
1047/// A point on a solid the section leaves whole (`exempt`, by reference) is
1048/// drawn whatever side it is on.
1049fn sectioned_away(mut report: PmiAnnotationReport, section: &anchor::HalfSpace, exempt: &dyn Fn(&str) -> bool) -> PmiAnnotationReport {
1050    if report.status != PmiStatus::Ok {
1051        return report;
1052    }
1053    let points: Vec<[f64; 3]> = match &report.geometry {
1054        PmiGeometry::Linear { a, b, component: None } => vec![*a, *b],
1055        // An aligned dimension's second point is the projected corner, not geometry.
1056        PmiGeometry::Linear { a, component: Some(_), .. } => vec![*a],
1057        PmiGeometry::Leader { targets, balloon: false, .. } => targets.clone(),
1058        PmiGeometry::Hole { anchor, .. } | PmiGeometry::Datum { anchor, .. } | PmiGeometry::Fcf { anchor, .. } => vec![*anchor],
1059        _ => return report,
1060    };
1061    // Each point's own reference where they pair one to one (a dimension's
1062    // two targets, a leader's targets); otherwise all of them must be exempt.
1063    let references = &report.references;
1064    let point_exempt = |index: usize| match references.len() == points.len() {
1065        true => exempt(&references[index]),
1066        false => !references.is_empty() && references.iter().all(|reference| exempt(reference)),
1067    };
1068    if points.iter().enumerate().all(|(index, point)| point_exempt(index) || section.keeps(*point, 1e-6)) {
1069        return report;
1070    }
1071    report.status = PmiStatus::Error;
1072    report.message = "it is on geometry the section removes in this view: move the section, Reverse it, or re-attach the annotation".into();
1073    report.text = String::new();
1074    report.value = None;
1075    report.geometry = PmiGeometry::None;
1076    report.plane = None;
1077    report
1078}
1079
1080/// The scene name of the solid a reference lies on: a vertex ref's solid, a
1081/// face's or an edge's owner, or the solid itself.
1082fn reference_solid(scene: &SceneMap, reference: &str) -> Option<String> {
1083    let reference = reference.trim();
1084    if let Some((solid, _)) = reference.split_once('@') {
1085        return Some(solid.to_string());
1086    }
1087    let handle = scene
1088        .resolve_face(reference)
1089        .map(|face| face.handle)
1090        .or_else(|| scene.resolve_edge(reference).map(|edge| edge.handle))
1091        .or_else(|| scene.resolve_solid(reference))?;
1092    scene.solids.iter().find(|(_, h)| **h == handle).map(|(name, _)| name.clone())
1093}
1094
1095/// Resolve one annotation through its type's resolver into a report row:
1096/// the type's measurement, then its annotation plane (`camera` orients the
1097/// plane toward the view; `None` for a view without a camera).
1098pub fn resolve_annotation(
1099    annotation: &PmiAnnotation,
1100    context: &PmiContext<'_>,
1101    camera: Option<&PmiCamera>,
1102) -> PmiAnnotationReport {
1103    let mut persistent_data = None;
1104    let mut plugin_replay = None;
1105    let outcome = match pmi_type(&annotation.kind) {
1106        None if annotation.kind.contains('/') && !annotation.enabled => Ok(Resolved {
1107            text: String::new(), value: None, unit: "", references: Vec::new(),
1108            geometry: PmiGeometry::None, default_label: annotation.label_world.unwrap_or([0.; 3]),
1109        }),
1110        Some(def) => (def.resolve)(annotation, context),
1111        None if annotation.kind.contains('/') => plugin::resolve(annotation, context).map(|(resolved, data, replay)| {
1112            persistent_data = Some(data);
1113            plugin_replay = Some(replay);
1114            resolved
1115        }),
1116        None => Err(format!("unknown PMI annotation type '{}'", annotation.kind)),
1117    }
1118    .and_then(|resolved| {
1119        let plane = annotation_plane(annotation, context, camera, &resolved.geometry)?;
1120        Ok((resolved, plane))
1121    });
1122    match outcome {
1123        Ok((resolved, plane)) => {
1124            let label = annotation.label_world.unwrap_or(resolved.default_label);
1125            PmiAnnotationReport {
1126                persistent_data,
1127                plugin_replay,
1128                id: annotation.id().to_string(),
1129                kind: annotation.kind.clone(),
1130                enabled: annotation.enabled,
1131                status: PmiStatus::Ok,
1132                message: String::new(),
1133                text: resolved.text,
1134                value: resolved.value,
1135                unit: resolved.unit.to_string(),
1136                references: resolved.references,
1137                label_world: plane.map_or(label, |plane| plane.project(label)),
1138                plane,
1139                geometry: resolved.geometry,
1140            }
1141        }
1142        Err(message) => PmiAnnotationReport {
1143            persistent_data: None,
1144            plugin_replay: None,
1145            id: annotation.id().to_string(),
1146            kind: annotation.kind.clone(),
1147            enabled: annotation.enabled,
1148            status: PmiStatus::Error,
1149            message,
1150            text: String::new(),
1151            value: None,
1152            unit: String::new(),
1153            references: Vec::new(),
1154            // A refused balloon's chip sits beside its occurrence, where the
1155            // bubble would have been, not at the origin.
1156            label_world: annotation
1157                .label_world
1158                .or_else(|| (annotation.kind == "balloon").then(|| annotations::balloon::error_bubble(annotation, context)).flatten())
1159                .unwrap_or([0.0; 3]),
1160            plane: None,
1161            geometry: PmiGeometry::None,
1162        },
1163    }
1164}
1165
1166/// Resolve an annotation's `plane` reference into its [`PmiPlane`]. The
1167/// reference must be a planar face or a reference plane, and the plane must
1168/// be parallel to what it carries — a linear dimension's direction, an
1169/// angle's plane, a circle's plane — since a dimension line drawn in a
1170/// non-parallel plane would be foreshortened and misstate the value. The
1171/// normal faces the view's camera and the text direction follows the
1172/// camera's right. `Ok(None)` when the annotation aligns to the view.
1173fn annotation_plane(
1174    annotation: &PmiAnnotation,
1175    context: &PmiContext<'_>,
1176    camera: Option<&PmiCamera>,
1177    geometry: &PmiGeometry,
1178) -> Result<Option<PmiPlane>, String> {
1179    use crate::SelectionGeometry;
1180    use resolve::{a3, perpendicular_in_plane, v3};
1181    let Some(name) = annotation.plane_ref() else {
1182        return Ok(None);
1183    };
1184    let (origin, normal) = match resolve::resolve_reference(context.scene, name)? {
1185        SelectionGeometry::Plane { origin, normal } => (origin, normal),
1186        _ => return Err(format!("annotation plane '{name}' must be a planar face or a reference plane")),
1187    };
1188    let mut normal = normal
1189        .normalized()
1190        .map_err(|_| format!("annotation plane '{name}' has no normal"))?;
1191    // sin of the largest tolerated tilt (~0.06°).
1192    const PARALLEL: f64 = 1e-3;
1193    match geometry {
1194        PmiGeometry::Linear { a, b, .. } => {
1195            let span = v3(*b).sub(v3(*a));
1196            if span.length() > 1e-9 && span.normalized().map(|d| d.dot(normal).abs()).unwrap_or(0.0) > PARALLEL {
1197                return Err(format!("annotation plane '{name}' is not parallel to the measured direction"));
1198            }
1199        }
1200        PmiGeometry::Angular { axis, .. } => {
1201            if v3(*axis).cross(normal).length() > PARALLEL {
1202                return Err(format!("annotation plane '{name}' is not parallel to the angle's plane"));
1203            }
1204        }
1205        PmiGeometry::Radial { axis, sphere: false, .. } => {
1206            if v3(*axis).cross(normal).length() > PARALLEL {
1207                return Err(format!("annotation plane '{name}' is not parallel to the circle's plane"));
1208            }
1209        }
1210        _ => {}
1211    }
1212    let x_axis = match camera {
1213        Some(camera) => {
1214            let view = v3(camera.view_direction());
1215            if normal.dot(view) > 0.0 {
1216                normal = normal.scale(-1.0);
1217            }
1218            perpendicular_in_plane(normal, view.cross(v3(camera.up)))
1219        }
1220        None => perpendicular_in_plane(normal, crate::Vec3::new(1.0, 0.0, 0.0)),
1221    };
1222    Ok(Some(PmiPlane {
1223        origin: a3(origin),
1224        normal: a3(normal),
1225        x_axis: a3(x_axis),
1226    }))
1227}
1228