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