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