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