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 annotations;
64pub mod font;
65pub mod layout;
66pub mod resolve;
67
68use serde::{Deserialize, Serialize};
69use serde_json::Value;
70use std::collections::BTreeMap;
71
72use crate::feature_pipeline::{Env, HistoryRequest, SceneMap, SelectionProbe};
73
74pub use annotations::{pmi_schema_catalogue, pmi_type, PmiTypeDef, PMI_TYPES};
75
76// ===========================================================================
77// The persisted block
78// ===========================================================================
79
80/// The document's `pmi` block.
81#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
82pub struct PmiState {
83    #[serde(default)]
84    pub views: Vec<PmiView>,
85    /// Mints every view AND annotation id (`VIEW{n}`, `DIM{n}`, …); monotonic,
86    /// never reused, re-seeded from the largest numeric suffix on load.
87    #[serde(default, rename = "idCounter")]
88    pub id_counter: u64,
89}
90
91/// A PMI view: a camera snapshot, display state and the annotations it owns.
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct PmiView {
94    pub id: String,
95    #[serde(default)]
96    pub name: String,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub camera: Option<PmiCamera>,
99    #[serde(default)]
100    pub display: PmiDisplay,
101    #[serde(default)]
102    pub annotations: Vec<PmiAnnotation>,
103}
104
105/// The camera snapshot: eye / target / up / projection / viewport size — the
106/// render crate's `ViewCamera` minus near/far, which are a per-frame depth
107/// window and never persisted. Applying a snapshot under another viewport
108/// aspect refits the frustum, preserving the apparent size at the target.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct PmiCamera {
111    pub eye: [f64; 3],
112    pub target: [f64; 3],
113    pub up: [f64; 3],
114    pub projection: PmiProjection,
115    /// `[width, height]` of the viewport the snapshot was taken in (CSS px).
116    #[serde(default = "default_viewport")]
117    pub viewport: [f64; 2],
118}
119
120fn default_viewport() -> [f64; 2] {
121    [1280.0, 800.0]
122}
123
124impl PmiCamera {
125    /// The unit viewing direction (eye → target).
126    pub fn view_direction(&self) -> [f64; 3] {
127        let d = [
128            self.target[0] - self.eye[0],
129            self.target[1] - self.eye[1],
130            self.target[2] - self.eye[2],
131        ];
132        let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
133        if len < 1e-12 {
134            [0.0, 0.0, -1.0]
135        } else {
136            [d[0] / len, d[1] / len, d[2] / len]
137        }
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142#[serde(tag = "kind", rename_all = "camelCase")]
143pub enum PmiProjection {
144    Orthographic {
145        #[serde(rename = "halfHeight")]
146        half_height: f64,
147    },
148    Perspective {
149        #[serde(rename = "fovYDeg")]
150        fov_y_deg: f64,
151    },
152}
153
154/// Per-view display state.
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct PmiDisplay {
157    /// Label text size in points, clamped to `[1, 288]`.
158    #[serde(default = "default_text_size", rename = "textSizePt")]
159    pub text_size_pt: f64,
160    #[serde(default)]
161    pub wireframe: bool,
162    /// Scene object NAMES hidden in this view (resolved to runtime visibility
163    /// on apply; a name that no longer exists is ignored).
164    #[serde(default)]
165    pub hidden: Vec<String>,
166}
167
168fn default_text_size() -> f64 {
169    12.0
170}
171
172impl Default for PmiDisplay {
173    fn default() -> Self {
174        Self {
175            text_size_pt: default_text_size(),
176            wireframe: false,
177            hidden: Vec::new(),
178        }
179    }
180}
181
182/// The text size clamp: `[1, 288]` points.
183pub fn clamp_text_size(size: f64) -> f64 {
184    if !size.is_finite() {
185        return default_text_size();
186    }
187    size.clamp(1.0, 288.0)
188}
189
190/// One annotation: schema-driven params keyed by its `type`.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct PmiAnnotation {
193    #[serde(rename = "type")]
194    pub kind: String,
195    #[serde(default = "default_true")]
196    pub enabled: bool,
197    #[serde(default, rename = "inputParams")]
198    pub params: Value,
199    /// The draggable label's world anchor. Absent until the user moves it —
200    /// the report then carries a default derived from the geometry.
201    #[serde(default, rename = "labelWorld", skip_serializing_if = "Option::is_none")]
202    pub label_world: Option<[f64; 3]>,
203}
204
205fn default_true() -> bool {
206    true
207}
208
209impl PmiAnnotation {
210    /// `inputParams.id` (empty when absent).
211    pub fn id(&self) -> &str {
212        self.params
213            .get("id")
214            .and_then(Value::as_str)
215            .unwrap_or("")
216    }
217
218    /// A string param, trimmed (`""` when absent or not a string).
219    pub fn text(&self, key: &str) -> &str {
220        self.params
221            .get(key)
222            .and_then(Value::as_str)
223            .map(str::trim)
224            .unwrap_or("")
225    }
226
227    /// A boolean param (`false` when absent).
228    pub fn flag(&self, key: &str) -> bool {
229        self.params
230            .get(key)
231            .and_then(Value::as_bool)
232            .unwrap_or(false)
233    }
234
235    /// A numeric param: a JSON number, or a string evaluated as an expression
236    /// against the document's variable sheet. `default` when absent / empty.
237    pub fn number(&self, key: &str, env: &Env, default: f64) -> Result<f64, String> {
238        match self.params.get(key) {
239            None | Some(Value::Null) => Ok(default),
240            Some(Value::Number(number)) => Ok(number.as_f64().unwrap_or(default)),
241            Some(Value::Bool(flag)) => Ok(if *flag { 1.0 } else { 0.0 }),
242            Some(Value::String(source)) => {
243                let source = source.trim();
244                if source.is_empty() {
245                    return Ok(default);
246                }
247                env.eval(source)
248                    .map_err(|error| format!("{key}: {error}"))
249            }
250            Some(other) => Err(format!("{key}: expected a number, got {other}")),
251        }
252    }
253
254    /// The `plane` reference (a planar face / reference plane name), when
255    /// the annotation is laid out in a picked plane rather than the view.
256    pub fn plane_ref(&self) -> Option<&str> {
257        let name = self.text("plane").trim();
258        (!name.is_empty()).then_some(name)
259    }
260
261    /// A reference param as a name list: a string, or an array of strings
262    /// (empty strings dropped).
263    pub fn references(&self, key: &str) -> Vec<String> {
264        match self.params.get(key) {
265            Some(Value::String(name)) => {
266                let name = name.trim();
267                if name.is_empty() {
268                    Vec::new()
269                } else {
270                    vec![name.to_string()]
271                }
272            }
273            Some(Value::Array(items)) => items
274                .iter()
275                .filter_map(Value::as_str)
276                .map(str::trim)
277                .filter(|name| !name.is_empty())
278                .map(String::from)
279                .collect(),
280            _ => Vec::new(),
281        }
282    }
283}
284
285impl PmiState {
286    /// Mint the next id with `prefix` (`VIEW` → `VIEW3`). The counter is
287    /// re-seeded first from the largest numeric suffix already in the block,
288    /// so an edited or merged document never hands out a colliding id.
289    pub fn next_id(&mut self, prefix: &str) -> String {
290        let seen = self.max_numeric_suffix();
291        if seen > self.id_counter {
292            self.id_counter = seen;
293        }
294        loop {
295            self.id_counter += 1;
296            let candidate = format!("{prefix}{}", self.id_counter);
297            if self.find_view(&candidate).is_none() && self.find_annotation(&candidate).is_none() {
298                return candidate;
299            }
300        }
301    }
302
303    fn max_numeric_suffix(&self) -> u64 {
304        let mut best = 0u64;
305        let mut consider = |id: &str| {
306            let digits = id
307                .bytes()
308                .rev()
309                .take_while(u8::is_ascii_digit)
310                .count();
311            if digits > 0 {
312                if let Ok(value) = id[id.len() - digits..].parse::<u64>() {
313                    best = best.max(value);
314                }
315            }
316        };
317        for view in &self.views {
318            consider(&view.id);
319            for annotation in &view.annotations {
320                consider(annotation.id());
321            }
322        }
323        best
324    }
325
326    pub fn find_view(&self, id: &str) -> Option<&PmiView> {
327        self.views.iter().find(|view| view.id == id)
328    }
329
330    pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PmiView> {
331        self.views.iter_mut().find(|view| view.id == id)
332    }
333
334    /// The annotation with `id` and its owning view.
335    pub fn find_annotation(&self, id: &str) -> Option<(&PmiView, &PmiAnnotation)> {
336        self.views.iter().find_map(|view| {
337            view.annotations
338                .iter()
339                .find(|annotation| annotation.id() == id)
340                .map(|annotation| (view, annotation))
341        })
342    }
343
344    /// The owning view id and index of annotation `id`.
345    pub fn locate_annotation(&self, id: &str) -> Option<(usize, usize)> {
346        self.views.iter().enumerate().find_map(|(view_index, view)| {
347            view.annotations
348                .iter()
349                .position(|annotation| annotation.id() == id)
350                .map(|index| (view_index, index))
351        })
352    }
353
354    pub fn find_annotation_mut(&mut self, id: &str) -> Option<&mut PmiAnnotation> {
355        self.views.iter_mut().find_map(|view| {
356            view.annotations
357                .iter_mut()
358                .find(|annotation| annotation.id() == id)
359        })
360    }
361
362    /// Every datum letter defined in the part (any view), in definition
363    /// order, with the defining annotation's id — the part-level datum
364    /// registry a feature control frame validates its references against.
365    pub fn datum_letters(&self) -> Vec<(String, String)> {
366        let mut out = Vec::new();
367        for view in &self.views {
368            for annotation in &view.annotations {
369                if annotation.kind == annotations::datum::DEF.type_id {
370                    let letter = annotation.text("letter").to_uppercase();
371                    if !letter.is_empty() {
372                        out.push((letter, annotation.id().to_string()));
373                    }
374                }
375            }
376        }
377        out
378    }
379
380    /// The next unused datum letter: A–Z skipping I, O and Q (ASME Y14.5),
381    /// then AA, AB, … (`None` only past ZZ).
382    pub fn next_datum_letter(&self) -> Option<String> {
383        let used: Vec<String> = self.datum_letters().into_iter().map(|(l, _)| l).collect();
384        let alphabet: Vec<char> = ('A'..='Z').filter(|c| !matches!(c, 'I' | 'O' | 'Q')).collect();
385        for letter in &alphabet {
386            let candidate = letter.to_string();
387            if !used.contains(&candidate) {
388                return Some(candidate);
389            }
390        }
391        for first in &alphabet {
392            for second in &alphabet {
393                let candidate = format!("{first}{second}");
394                if !used.contains(&candidate) {
395                    return Some(candidate);
396                }
397            }
398        }
399        None
400    }
401
402    /// Whether the block carries anything worth persisting.
403    pub fn is_empty(&self) -> bool {
404        self.views.is_empty() && self.id_counter == 0
405    }
406}
407
408// ===========================================================================
409// The resolved report
410// ===========================================================================
411
412/// The tail's resolution of every view's annotations.
413#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
414pub struct PmiReport {
415    pub views: Vec<PmiViewReport>,
416}
417
418impl PmiReport {
419    pub fn view(&self, id: &str) -> Option<&PmiViewReport> {
420        self.views.iter().find(|view| view.id == id)
421    }
422
423    pub fn annotation(&self, id: &str) -> Option<&PmiAnnotationReport> {
424        self.views
425            .iter()
426            .find_map(|view| view.annotations.iter().find(|a| a.id == id))
427    }
428
429    pub fn annotation_mut(&mut self, id: &str) -> Option<&mut PmiAnnotationReport> {
430        self.views
431            .iter_mut()
432            .find_map(|view| view.annotations.iter_mut().find(|a| a.id == id))
433    }
434}
435
436#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
437pub struct PmiViewReport {
438    pub id: String,
439    pub annotations: Vec<PmiAnnotationReport>,
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "kebab-case")]
444pub enum PmiStatus {
445    /// Resolved: `text` / `value` / `geometry` are live.
446    Ok,
447    /// An unresolved anchor, a dangling datum reference, an unsupported
448    /// pairing …: `message` says which. The annotation stays listed.
449    Error,
450}
451
452/// One resolved annotation.
453#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
454pub struct PmiAnnotationReport {
455    pub id: String,
456    #[serde(rename = "type")]
457    pub kind: String,
458    pub enabled: bool,
459    pub status: PmiStatus,
460    #[serde(default)]
461    pub message: String,
462    /// The display text (value + tolerance block, note / leader text, hole
463    /// callout, datum letter, FCF cells joined).
464    #[serde(default)]
465    pub text: String,
466    /// The measured value (length in model units, angle in degrees).
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub value: Option<f64>,
469    /// `"mm"` / `"deg"` / `""`.
470    #[serde(default)]
471    pub unit: String,
472    /// The scene entity names the annotation resolved (for hover
473    /// highlighting and the STEP shape aspects), in reference order.
474    #[serde(default)]
475    pub references: Vec<String>,
476    /// The label anchor: the stored `labelWorld`, else the geometry's default
477    /// — projected onto the annotation plane when one is picked.
478    #[serde(rename = "labelWorld")]
479    pub label_world: [f64; 3],
480    /// The picked annotation plane; `None` aligns to the view camera.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub plane: Option<PmiPlane>,
483    pub geometry: PmiGeometry,
484}
485
486/// A resolved annotation plane: the plane the annotation lies in (its
487/// `plane` reference — a planar face or a reference plane), `normal`
488/// oriented toward the view's captured camera, `x_axis` the in-plane text
489/// direction (the camera's right projected into the plane). The viewport
490/// overlay and the AP242 `ANNOTATION_PLANE` both lay the annotation out in
491/// this frame.
492#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
493pub struct PmiPlane {
494    pub origin: [f64; 3],
495    pub normal: [f64; 3],
496    #[serde(rename = "xAxis")]
497    pub x_axis: [f64; 3],
498}
499
500impl PmiPlane {
501    /// `point` projected onto the plane along its normal.
502    pub fn project(&self, point: [f64; 3]) -> [f64; 3] {
503        let n = self.normal;
504        let d = (point[0] - self.origin[0]) * n[0] + (point[1] - self.origin[1]) * n[1] + (point[2] - self.origin[2]) * n[2];
505        [point[0] - n[0] * d, point[1] - n[1] * d, point[2] - n[2] * d]
506    }
507
508    /// The in-plane up direction (`normal × x_axis`).
509    pub fn y_axis(&self) -> [f64; 3] {
510        let n = self.normal;
511        let x = self.x_axis;
512        [n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0]]
513    }
514
515    /// The ray `origin + t·dir` hit on the plane, if not parallel.
516    pub fn hit(&self, origin: [f64; 3], dir: [f64; 3]) -> Option<[f64; 3]> {
517        let n = self.normal;
518        let denominator = dir[0] * n[0] + dir[1] * n[1] + dir[2] * n[2];
519        if denominator.abs() < 1e-12 {
520            return None;
521        }
522        let diff = [self.origin[0] - origin[0], self.origin[1] - origin[1], self.origin[2] - origin[2]];
523        let t = (diff[0] * n[0] + diff[1] * n[1] + diff[2] * n[2]) / denominator;
524        Some([origin[0] + dir[0] * t, origin[1] + dir[1] * t, origin[2] + dir[2] * t])
525    }
526}
527
528/// The analytic geometry an annotation resolved to — what the presentation
529/// ([`layout`]) and the STEP semantic export are built from.
530#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
531#[serde(tag = "kind", rename_all = "camelCase")]
532pub enum PmiGeometry {
533    None,
534    /// Measured from `a` to `b` (world). `component` names the X/Y/Z axis
535    /// when the dimension is an aligned component, else `None`.
536    Linear {
537        a: [f64; 3],
538        b: [f64; 3],
539        #[serde(default, skip_serializing_if = "Option::is_none")]
540        component: Option<char>,
541    },
542    Radial {
543        center: [f64; 3],
544        axis: [f64; 3],
545        radius: f64,
546        diameter: bool,
547        sphere: bool,
548    },
549    /// The arc sweeps `degrees` from `dir_a` about `axis` (right-handed) at
550    /// `vertex`.
551    Angular {
552        vertex: [f64; 3],
553        #[serde(rename = "dirA")]
554        dir_a: [f64; 3],
555        #[serde(rename = "dirB")]
556        dir_b: [f64; 3],
557        axis: [f64; 3],
558        degrees: f64,
559    },
560    Leader {
561        targets: Vec<[f64; 3]>,
562        dot: bool,
563    },
564    Note {
565        position: [f64; 3],
566    },
567    Hole {
568        anchor: [f64; 3],
569        normal: [f64; 3],
570    },
571    /// Display-only: pose `solids` by the delta while the view is active.
572    Explode {
573        solids: Vec<String>,
574        translate: [f64; 3],
575        #[serde(rename = "rotateDeg")]
576        rotate_deg: [f64; 3],
577        scale: [f64; 3],
578        /// The rotation / scale pivot: the targets' aggregate bbox center.
579        center: [f64; 3],
580        trace: bool,
581    },
582    Datum {
583        anchor: [f64; 3],
584        normal: [f64; 3],
585        letter: String,
586    },
587    Fcf {
588        anchor: [f64; 3],
589        normal: [f64; 3],
590        frame: FcfFrame,
591    },
592}
593
594/// The cells of a feature control frame.
595#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
596pub struct FcfFrame {
597    /// The characteristic id (`flatness`, `position`, …).
598    pub characteristic: String,
599    /// Its Unicode symbol (the glyph key).
600    pub symbol: String,
601    /// The tolerance cell text (`⌀0.1 Ⓜ`).
602    pub zone: String,
603    /// The datum reference cells (`A`, `B Ⓜ`).
604    pub datums: Vec<String>,
605}
606
607// ===========================================================================
608// Value formatting (shared by the viewport and the STEP presentation)
609// ===========================================================================
610
611/// The tolerance block of a dimension: `tolMode` none / symmetric / deviation
612/// / limits with `tolUpper` / `tolLower`.
613#[derive(Debug, Clone, Copy, PartialEq)]
614pub struct ToleranceBlock {
615    pub mode: ToleranceMode,
616    pub upper: f64,
617    pub lower: f64,
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
621pub enum ToleranceMode {
622    None,
623    Symmetric,
624    Deviation,
625    Limits,
626}
627
628impl ToleranceMode {
629    pub fn parse(text: &str) -> Self {
630        match text.trim().to_ascii_lowercase().as_str() {
631            "symmetric" => ToleranceMode::Symmetric,
632            "deviation" => ToleranceMode::Deviation,
633            "limits" => ToleranceMode::Limits,
634            _ => ToleranceMode::None,
635        }
636    }
637
638    pub fn as_str(self) -> &'static str {
639        match self {
640            ToleranceMode::None => "none",
641            ToleranceMode::Symmetric => "symmetric",
642            ToleranceMode::Deviation => "deviation",
643            ToleranceMode::Limits => "limits",
644        }
645    }
646}
647
648impl ToleranceBlock {
649    /// Read the block off an annotation's params.
650    pub fn read(annotation: &PmiAnnotation, env: &Env) -> Result<Self, String> {
651        Ok(Self {
652            mode: ToleranceMode::parse(annotation.text("tolMode")),
653            upper: annotation.number("tolUpper", env, 0.0)?.abs(),
654            lower: annotation.number("tolLower", env, 0.0)?.abs(),
655        })
656    }
657
658    /// The signed bounds as offsets from the nominal (`lower ≤ 0 ≤ upper`),
659    /// `None` when the mode carries no tolerance.
660    pub fn bounds(&self) -> Option<(f64, f64)> {
661        match self.mode {
662            ToleranceMode::None => None,
663            ToleranceMode::Symmetric => Some((-self.upper, self.upper)),
664            ToleranceMode::Deviation | ToleranceMode::Limits => Some((-self.lower, self.upper)),
665        }
666    }
667}
668
669/// `value` with `decimals` places (`0..=8`), no exponent.
670pub fn format_number(value: f64, decimals: usize) -> String {
671    let decimals = decimals.min(8);
672    let text = format!("{:.*}", decimals, value);
673    // `-0.000` reads as a sign error on a dimension.
674    if text.starts_with('-') && text[1..].bytes().all(|b| b == b'0' || b == b'.') {
675        text[1..].to_string()
676    } else {
677        text
678    }
679}
680
681/// The dimension text: `prefix` (`⌀` / `R` / `""`) + the nominal (+ the
682/// tolerance block) + `suffix` (`°` / `""`); a reference dimension is
683/// parenthesized and shows no tolerance.
684pub fn format_dimension(
685    value: f64,
686    decimals: usize,
687    tolerance: &ToleranceBlock,
688    is_reference: bool,
689    prefix: &str,
690    suffix: &str,
691) -> String {
692    let nominal = format!("{prefix}{}{suffix}", format_number(value, decimals));
693    if is_reference {
694        return format!("({nominal})");
695    }
696    match tolerance.mode {
697        ToleranceMode::None => nominal,
698        ToleranceMode::Symmetric => format!(
699            "{nominal} \u{00B1}{}{suffix}",
700            format_number(tolerance.upper, decimals)
701        ),
702        ToleranceMode::Deviation => format!(
703            "{nominal} +{}{suffix}/\u{2212}{}{suffix}",
704            format_number(tolerance.upper, decimals),
705            format_number(tolerance.lower, decimals)
706        ),
707        ToleranceMode::Limits => format!(
708            "{prefix}{}{suffix} / {prefix}{}{suffix}",
709            format_number(value + tolerance.upper, decimals),
710            format_number(value - tolerance.lower, decimals)
711        ),
712    }
713}
714
715// ===========================================================================
716// The history tail
717// ===========================================================================
718
719/// What the annotation resolvers read: the live scene, the request (hole
720/// features for callouts, the datum registry) and the expression sheet.
721pub struct PmiContext<'a> {
722    pub scene: &'a SceneMap,
723    pub request: &'a HistoryRequest,
724    pub env: &'a Env,
725    /// The part-level datum registry (letter → annotation id).
726    pub datums: BTreeMap<String, String>,
727}
728
729/// The outcome of one annotation's resolver.
730pub struct Resolved {
731    pub text: String,
732    pub value: Option<f64>,
733    pub unit: &'static str,
734    pub references: Vec<String>,
735    pub geometry: PmiGeometry,
736    /// The default label anchor when the annotation stores none.
737    pub default_label: [f64; 3],
738}
739
740/// Resolve every view's annotations against `scene`. `None` when the request
741/// carries no `pmi` block (a document without PMI reports nothing).
742pub(crate) fn finish_history_run(
743    request: &HistoryRequest,
744    scene: &SceneMap,
745    env: &Env,
746) -> Option<PmiReport> {
747    let state = request.pmi.as_ref()?;
748    Some(resolve_state(state, scene, request, env))
749}
750
751/// Resolve `state` (the tail's body, callable on any scene).
752pub fn resolve_state(
753    state: &PmiState,
754    scene: &SceneMap,
755    request: &HistoryRequest,
756    env: &Env,
757) -> PmiReport {
758    // The datum registry: first definition of a letter wins; a duplicate is
759    // reported on the later annotation by the datum resolver.
760    let mut datums: BTreeMap<String, String> = BTreeMap::new();
761    for (letter, id) in state.datum_letters() {
762        datums.entry(letter).or_insert(id);
763    }
764    let context = PmiContext {
765        scene,
766        request,
767        env,
768        datums,
769    };
770    PmiReport {
771        views: state
772            .views
773            .iter()
774            .map(|view| PmiViewReport {
775                id: view.id.clone(),
776                annotations: view
777                    .annotations
778                    .iter()
779                    .map(|annotation| resolve_annotation(annotation, &context, view.camera.as_ref()))
780                    .collect(),
781            })
782            .collect(),
783    }
784}
785
786/// Resolve one annotation through its type's resolver into a report row:
787/// the type's measurement, then its annotation plane (`camera` orients the
788/// plane toward the view; `None` for a view without a camera).
789pub fn resolve_annotation(
790    annotation: &PmiAnnotation,
791    context: &PmiContext<'_>,
792    camera: Option<&PmiCamera>,
793) -> PmiAnnotationReport {
794    let outcome = match pmi_type(&annotation.kind) {
795        Some(def) => (def.resolve)(annotation, context),
796        None => Err(format!("unknown PMI annotation type '{}'", annotation.kind)),
797    }
798    .and_then(|resolved| {
799        let plane = annotation_plane(annotation, context, camera, &resolved.geometry)?;
800        Ok((resolved, plane))
801    });
802    match outcome {
803        Ok((resolved, plane)) => {
804            let label = annotation.label_world.unwrap_or(resolved.default_label);
805            PmiAnnotationReport {
806                id: annotation.id().to_string(),
807                kind: annotation.kind.clone(),
808                enabled: annotation.enabled,
809                status: PmiStatus::Ok,
810                message: String::new(),
811                text: resolved.text,
812                value: resolved.value,
813                unit: resolved.unit.to_string(),
814                references: resolved.references,
815                label_world: plane.map_or(label, |plane| plane.project(label)),
816                plane,
817                geometry: resolved.geometry,
818            }
819        }
820        Err(message) => PmiAnnotationReport {
821            id: annotation.id().to_string(),
822            kind: annotation.kind.clone(),
823            enabled: annotation.enabled,
824            status: PmiStatus::Error,
825            message,
826            text: String::new(),
827            value: None,
828            unit: String::new(),
829            references: Vec::new(),
830            label_world: annotation.label_world.unwrap_or([0.0; 3]),
831            plane: None,
832            geometry: PmiGeometry::None,
833        },
834    }
835}
836
837/// Resolve an annotation's `plane` reference into its [`PmiPlane`]. The
838/// reference must be a planar face or a reference plane, and the plane must
839/// be parallel to what it carries — a linear dimension's direction, an
840/// angle's plane, a circle's plane — since a dimension line drawn in a
841/// non-parallel plane would be foreshortened and misstate the value. The
842/// normal faces the view's camera and the text direction follows the
843/// camera's right. `Ok(None)` when the annotation aligns to the view.
844fn annotation_plane(
845    annotation: &PmiAnnotation,
846    context: &PmiContext<'_>,
847    camera: Option<&PmiCamera>,
848    geometry: &PmiGeometry,
849) -> Result<Option<PmiPlane>, String> {
850    use crate::SelectionGeometry;
851    use resolve::{a3, perpendicular_in_plane, v3};
852    let Some(name) = annotation.plane_ref() else {
853        return Ok(None);
854    };
855    let (origin, normal) = match resolve::resolve_reference(context.scene, name)? {
856        SelectionGeometry::Plane { origin, normal } => (origin, normal),
857        _ => return Err(format!("annotation plane '{name}' must be a planar face or a reference plane")),
858    };
859    let mut normal = normal
860        .normalized()
861        .map_err(|_| format!("annotation plane '{name}' has no normal"))?;
862    // sin of the largest tolerated tilt (~0.06°).
863    const PARALLEL: f64 = 1e-3;
864    match geometry {
865        PmiGeometry::Linear { a, b, .. } => {
866            let span = v3(*b).sub(v3(*a));
867            if span.length() > 1e-9 && span.normalized().map(|d| d.dot(normal).abs()).unwrap_or(0.0) > PARALLEL {
868                return Err(format!("annotation plane '{name}' is not parallel to the measured direction"));
869            }
870        }
871        PmiGeometry::Angular { axis, .. } => {
872            if v3(*axis).cross(normal).length() > PARALLEL {
873                return Err(format!("annotation plane '{name}' is not parallel to the angle's plane"));
874            }
875        }
876        PmiGeometry::Radial { axis, sphere: false, .. } => {
877            if v3(*axis).cross(normal).length() > PARALLEL {
878                return Err(format!("annotation plane '{name}' is not parallel to the circle's plane"));
879            }
880        }
881        _ => {}
882    }
883    let x_axis = match camera {
884        Some(camera) => {
885            let view = v3(camera.view_direction());
886            if normal.dot(view) > 0.0 {
887                normal = normal.scale(-1.0);
888            }
889            perpendicular_in_plane(normal, view.cross(v3(camera.up)))
890        }
891        None => perpendicular_in_plane(normal, crate::Vec3::new(1.0, 0.0, 0.0)),
892    };
893    Ok(Some(PmiPlane {
894        origin: a3(origin),
895        normal: a3(normal),
896        x_axis: a3(x_axis),
897    }))
898}
899
900/// The kind-level selection summary a PMI type predicate reads: how many
901/// named entities of each kind are selected. Both plain and component
902/// geometry count (PMI annotates either).
903pub fn selection_total(probe: &SelectionProbe) -> usize {
904    probe.faces + probe.edges + probe.vertices + probe.planes + probe.solids
905}
906
907// BREP private tests: d0001cf5a2544890