Skip to main content

brep_render/sheets/
dimension.rs

1//! Sheet dimensions — a dimension drawn ON the paper, anchored to the
2//! projected geometry of a placed view rather than to a PMI annotation.
3//!
4//! # The anchor space
5//!
6//! Every annotation a sheet drew before this module came from a saved PMI
7//! view: it was authored in 3D, resolved by the kernel, and the sheet only
8//! projected it. A SHEET dimension is authored on the paper, so it needs an
9//! anchor space of its own — and the one thing it must NOT be is a paper
10//! position, because the paper position of everything a placement draws moves
11//! whenever the camera, the scale, the placement or the model moves.
12//!
13//! A [`SheetAnchor`] therefore names a point of a placed view's projection by
14//! MODEL IDENTITY:
15//!
16//! | kind | names | the point |
17//! |---|---|---|
18//! | `vertex` | a placement, a solid and a display vertex's topology id | the vertex, projected |
19//! | `edge` | a placement, a solid, an edge NAME and a fraction | that fraction along the edge by arc length, projected |
20//! | `circle` | a placement, a solid and a CIRCULAR edge's name | the circle's centre, projected |
21//!
22//! An ANGULAR dimension names two `edge` anchors and measures between the two
23//! EDGES rather than between the two points: the point picks which of the four
24//! angles at the crossing is meant (each ray leaves the vertex toward its own
25//! anchor) and the direction comes from the edge. So the same three candidates
26//! per edge serve both, and no fifth anchor kind is needed.
27//!
28//! and its reference string is `{placement}:{kind}:{solid}#{entity}`, with the
29//! fraction after an `@` on an edge (`SV2:edge:Box#Box_NX|Box_NZ[0]@0.5`).
30//! Edge names are the kernel's own pipeline names and a display vertex's
31//! topology id is stable across a parameter edit, so an anchor FOLLOWS its
32//! geometry: the projection re-runs on every camera, scale, placement and
33//! model change, and the anchor is re-resolved against it each time.
34//!
35//! An anchor whose geometry is GONE — the edge no longer exists after a model
36//! edit, the placement was removed — is not dropped. The dimension keeps its
37//! row, [`DimensionDrawing::error`] carries the reason, and the sheet draws it
38//! in the unresolved style the 3D overlay uses for an annotation that will not
39//! resolve. A drawing that silently loses a dimension is worse than one that
40//! says which dimension it lost.
41//!
42//! **A HIDDEN edge is still an anchor.** The hidden-line pass decides which
43//! model LINES are inked; it has no vote on the anchor space, which is the
44//! model's topology. An anchor on an edge the pass removed resolves exactly as
45//! one on a visible edge, and the dimension DRAWS to it — extension line,
46//! arrowhead and all — landing on a part of the paper where no model line was
47//! drawn. That is what dimensioning a hidden feature looks like on a drawing.
48//! The viewport's picker offers every anchor of a placement for the same
49//! reason: it is picking model geometry, not ink.
50//!
51//! # The drawing
52//!
53//! The layout is the SHARED one — `pmi_present`, the same function the 3D
54//! overlay, the STEP presentation and every placed annotation go through — run
55//! in a frame that IS the paper: x to the right, y UP, z out of the page, in
56//! paper millimetres. So a sheet dimension's extension lines, dimension line,
57//! arrowheads, value run and line breaking are the ones every other dimension
58//! in the product has, and its text is flat on the sheet by construction —
59//! there is no annotation plane to project out of.
60//!
61//! Two things this module does that the shared layout cannot:
62//!
63//! - **The component pair.** A HORIZONTAL or VERTICAL dimension measures one
64//!   component, and the layout draws the dimension line along `b - a`. The
65//!   kernel's own X/Y/Z rule answers that by replacing the second point with
66//!   its projection onto the measured axis, and this reads the same rule in
67//!   the sheet's own axes.
68//! - **Re-seating the extension lines.** Because the layout measured that
69//!   PROJECTED pair, one of its two extension lines starts at the projected
70//!   point rather than at the anchor it belongs to. Each is re-seated on its
71//!   true anchor and given the layout's own overshoot past the dimension line,
72//!   so a horizontal dimension between two points at different heights reaches
73//!   both of them.
74
75use serde::{Deserialize, Serialize};
76use serde_json::{json, Value};
77
78use super::project::{AnchorPoint, Measures, SheetText, ViewDrawing};
79use brep_kernel::{format_dimension, pmi_present, PmiGeometry, PmiLayoutStyle, ToleranceBlock, ToleranceMode};
80
81/// The dimension kinds.
82pub const LINEAR: &str = "linear";
83pub const RADIAL: &str = "radial";
84pub const DIAMETRAL: &str = "diametral";
85pub const ANGULAR: &str = "angular";
86pub const KINDS: &[&str] = &[LINEAR, RADIAL, DIAMETRAL, ANGULAR];
87
88/// How many anchors a dimension of `kind` takes: TWO for a linear one (the two
89/// measured points) and for an angular one (the two measured EDGES), one for a
90/// radial or diametral one (the circle).
91///
92/// The ONE reading of that count. `SheetDimension::anchor_count` is the layout
93/// asking it and the sheet reference picker is the other asker (a full list
94/// makes room for a new pick), and a kind whose two answers disagreed would be
95/// a picker that collects what the layout refuses.
96pub fn anchor_count(kind: &str) -> usize {
97    if kind.eq_ignore_ascii_case(LINEAR) || kind.eq_ignore_ascii_case(ANGULAR) {
98        2
99    } else {
100        1
101    }
102}
103
104/// A linear dimension's alignment: which direction it measures along.
105pub const HORIZONTAL: &str = "horizontal";
106pub const VERTICAL: &str = "vertical";
107pub const ALIGNED: &str = "aligned";
108pub const ALIGNMENTS: &[&str] = &[HORIZONTAL, VERTICAL, ALIGNED];
109
110/// The anchor kinds.
111pub const VERTEX: &str = "vertex";
112pub const EDGE: &str = "edge";
113pub const CIRCLE: &str = "circle";
114
115/// A new dimension's offset from its anchors, paper millimetres.
116pub const DEFAULT_OFFSET_MM: f64 = 12.0;
117
118/// Decimals in a sheet dimension's value, matching the kernel's own linear
119/// default.
120pub const DEFAULT_PRECISION: u32 = 3;
121
122/// The direction a RADIAL or DIAMETRAL dimension's leader leaves the centre,
123/// on the paper: up and to the right at 45°. A radial dimension's offset is
124/// one number (the distance from the circle outward), so the direction is a
125/// convention rather than a stored value.
126const RADIAL_DIR: [f64; 2] = [std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2];
127
128// ===========================================================================
129// The tolerance block
130// ===========================================================================
131
132/// The tolerance block a sheet dimension or an ordinate set carries: the PMI
133/// dimension's own four params under the PMI's own keys (`tolMode`,
134/// `tolUpper`, `tolLower`, `isReference`), read into the kernel's
135/// [`ToleranceBlock`] and written by the kernel's [`format_dimension`] — so a
136/// sheet value reads `10.000 ±0.050` exactly as the viewport's label and the
137/// STEP presentation of a PMI dimension do. Every key is omitted while it
138/// holds its default, so a sheet saved before the block re-serializes
139/// byte-identically.
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct SheetTolerance {
142    /// One of [`tolerance_modes`].
143    #[serde(default = "default_tol_mode", rename = "tolMode", skip_serializing_if = "is_no_tolerance")]
144    pub mode: String,
145    /// The upper deviation — the ± value of a symmetric block.
146    #[serde(default, rename = "tolUpper", skip_serializing_if = "is_zero")]
147    pub upper: f64,
148    /// The lower deviation (deviation and limits blocks).
149    #[serde(default, rename = "tolLower", skip_serializing_if = "is_zero")]
150    pub lower: f64,
151    /// A REFERENCE dimension: parenthesized, and no tolerance shown.
152    #[serde(default, rename = "isReference", skip_serializing_if = "is_false")]
153    pub reference: bool,
154}
155
156impl Default for SheetTolerance {
157    fn default() -> SheetTolerance {
158        SheetTolerance { mode: default_tol_mode(), upper: 0.0, lower: 0.0, reference: false }
159    }
160}
161
162fn default_tol_mode() -> String {
163    ToleranceMode::None.as_str().to_string()
164}
165
166fn is_no_tolerance(mode: &String) -> bool {
167    ToleranceMode::parse(mode) == ToleranceMode::None
168}
169
170fn is_zero(value: &f64) -> bool {
171    *value == 0.0
172}
173
174fn is_false(flag: &bool) -> bool {
175    !*flag
176}
177
178/// The tolerance modes, in the PMI form's order, read off the kernel's own
179/// [`ToleranceMode`] so the two dialogs cannot offer different lists.
180pub fn tolerance_modes() -> Vec<&'static str> {
181    [ToleranceMode::None, ToleranceMode::Symmetric, ToleranceMode::Deviation, ToleranceMode::Limits]
182        .iter()
183        .map(|mode| mode.as_str())
184        .collect()
185}
186
187impl SheetTolerance {
188    /// The kernel's block — magnitudes, as `ToleranceBlock::read` takes them.
189    pub fn block(&self) -> ToleranceBlock {
190        ToleranceBlock { mode: ToleranceMode::parse(&self.mode), upper: self.upper.abs(), lower: self.lower.abs() }
191    }
192
193    /// No tolerance and not a reference: the plain formatted number, and the
194    /// one state in which a sheet dimension may inherit a PMI dimension's.
195    pub fn is_plain(&self) -> bool {
196        self.block().mode == ToleranceMode::None && !self.reference
197    }
198
199    /// Write the four params into a form's params object.
200    pub fn write_params(&self, params: &mut Value) {
201        params["tolMode"] = json!(ToleranceMode::parse(&self.mode).as_str());
202        params["tolUpper"] = json!(self.upper);
203        params["tolLower"] = json!(self.lower);
204        params["isReference"] = json!(self.reference);
205    }
206
207    /// Apply an edited form's four params, refusing a mode the kernel does not
208    /// know rather than reading it as none.
209    pub fn apply(&mut self, params: &Value) -> Result<(), String> {
210        if let Some(mode) = params.get("tolMode").and_then(Value::as_str) {
211            let mode = mode.trim();
212            let Some(known) = tolerance_modes().into_iter().find(|known| known.eq_ignore_ascii_case(mode)) else {
213                return Err(format!("no tolerance mode '{mode}' (one of {})", tolerance_modes().join(", ")));
214            };
215            self.mode = known.to_string();
216        }
217        for (key, slot) in [("tolUpper", &mut self.upper), ("tolLower", &mut self.lower)] {
218            if let Some(value) = params.get(key).and_then(Value::as_f64) {
219                if !value.is_finite() {
220                    return Err(format!("{key}: {value} is not a deviation"));
221                }
222                *slot = value.abs();
223            }
224        }
225        if let Some(reference) = params.get("isReference").and_then(Value::as_bool) {
226            self.reference = reference;
227        }
228        Ok(())
229    }
230
231    /// The four fields of a dialog schema, labelled and hinted as the PMI
232    /// dimension form labels them.
233    pub fn schema_fields(schema: &mut serde_json::Map<String, Value>) {
234        schema.insert(
235            "isReference".into(),
236            json!({ "type": "boolean", "label": "Reference", "default_value": false, "hint": "A reference dimension: shown in parentheses, no tolerance" }),
237        );
238        schema.insert(
239            "tolMode".into(),
240            json!({
241                "type": "options", "label": "Tolerance", "options": tolerance_modes(), "default_value": ToleranceMode::None.as_str(),
242                "hint": "none \u{00B7} \u{00B1} symmetric \u{00B7} +upper/\u{2212}lower deviation \u{00B7} upper/lower limit values"
243            }),
244        );
245        schema.insert(
246            "tolUpper".into(),
247            json!({ "type": "number", "label": "Upper (+)", "default_value": 0.0, "step": 0.01, "hint": "The upper deviation (the \u{00B1} value for symmetric)" }),
248        );
249        schema.insert(
250            "tolLower".into(),
251            json!({ "type": "number", "label": "Lower (\u{2212})", "default_value": 0.0, "step": 0.01, "hint": "The lower deviation (deviation / limits modes)" }),
252        );
253    }
254}
255
256// ===========================================================================
257// The block
258// ===========================================================================
259
260/// One dimension drawn on the sheet.
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262pub struct SheetDimension {
263    pub id: String,
264    /// [`LINEAR`], [`RADIAL`], [`DIAMETRAL`] or [`ANGULAR`].
265    #[serde(default = "default_kind")]
266    pub kind: String,
267    /// A linear dimension's measured direction ([`ALIGNMENTS`]); unread by a
268    /// radial or diametral one, which measures a circle, and by an angular
269    /// one, whose direction is the two edges'.
270    #[serde(default = "default_alignment")]
271    pub alignment: String,
272    /// The anchor references — two for a linear dimension, one for a radial or
273    /// diametral one. Stored as their reference STRINGS, the convention every
274    /// other reference in the document follows.
275    #[serde(default)]
276    pub anchors: Vec<String>,
277    /// How far the dimension line sits from the anchors, paper millimetres.
278    /// Signed: the sign picks the side.
279    #[serde(default = "default_offset", rename = "offsetMm")]
280    pub offset_mm: f64,
281    /// Decimals in the value.
282    #[serde(default = "default_precision")]
283    pub precision: u32,
284    /// The tolerance block the value is written with.
285    #[serde(flatten)]
286    pub tolerance: SheetTolerance,
287}
288
289fn default_kind() -> String {
290    LINEAR.to_string()
291}
292
293fn default_alignment() -> String {
294    ALIGNED.to_string()
295}
296
297fn default_offset() -> f64 {
298    DEFAULT_OFFSET_MM
299}
300
301fn default_precision() -> u32 {
302    DEFAULT_PRECISION
303}
304
305impl SheetDimension {
306    /// A new dimension of `kind` / `alignment` over `anchors`.
307    pub fn new(id: String, kind: &str, alignment: &str, anchors: Vec<String>) -> SheetDimension {
308        SheetDimension {
309            id,
310            kind: kind.to_string(),
311            alignment: alignment.to_string(),
312            anchors,
313            offset_mm: DEFAULT_OFFSET_MM,
314            precision: DEFAULT_PRECISION,
315            tolerance: SheetTolerance::default(),
316        }
317    }
318
319    /// How many anchors this kind takes — [`anchor_count`], which the picker
320    /// reads too.
321    pub fn anchor_count(&self) -> usize {
322        anchor_count(&self.kind)
323    }
324
325    /// The params the **Sheet dimension** form edits.
326    pub fn params(&self) -> Value {
327        let mut params = json!({
328            "id": self.id,
329            "kind": self.kind,
330            "alignment": self.alignment,
331            "anchors": self.anchors,
332            "offsetMm": self.offset_mm,
333            "precision": self.precision,
334        });
335        self.tolerance.write_params(&mut params);
336        params
337    }
338
339    /// Apply an edited **Sheet dimension** form. `id` is identity and is never
340    /// taken from the form.
341    pub fn apply(&mut self, params: &Value) -> Result<(), String> {
342        if let Some(kind) = params.get("kind").and_then(Value::as_str) {
343            let kind = kind.trim();
344            let Some(known) = KINDS.iter().find(|k| k.eq_ignore_ascii_case(kind)) else {
345                return Err(format!("no dimension kind '{kind}'"));
346            };
347            self.kind = (*known).to_string();
348        }
349        if let Some(alignment) = params.get("alignment").and_then(Value::as_str) {
350            let alignment = alignment.trim();
351            let Some(known) = ALIGNMENTS.iter().find(|a| a.eq_ignore_ascii_case(alignment)) else {
352                return Err(format!("no alignment '{alignment}'"));
353            };
354            self.alignment = (*known).to_string();
355        }
356        if let Some(anchors) = params.get("anchors") {
357            let list: Vec<String> = crate::json_support::string_values(Some(anchors))
358                .map(str::to_string)
359                .collect();
360            // Every anchor the form keeps has to BE an anchor: a reference row
361            // is free text, and a dimension that stored one it cannot parse
362            // would report the same "lost anchor" a real model edit reports.
363            for anchor in &list {
364                SheetAnchor::parse(anchor)?;
365            }
366            self.anchors = list;
367        }
368        apply_offset_precision(params, &mut self.offset_mm, &mut self.precision)?;
369        self.tolerance.apply(params)
370    }
371}
372
373/// Apply the numeric layout fields shared by dimensions and ordinate sets.
374/// Missing or non-numeric values leave the current setting unchanged.
375pub(super) fn apply_offset_precision(
376    params: &Value,
377    offset_mm: &mut f64,
378    precision: &mut u32,
379) -> Result<(), String> {
380    if let Some(offset) = params.get("offsetMm").and_then(Value::as_f64) {
381        if !offset.is_finite() {
382            return Err(format!("offsetMm: {offset} is not a length"));
383        }
384        *offset_mm = offset;
385    }
386    if let Some(value) = params.get("precision").and_then(Value::as_f64) {
387        if !value.is_finite() || !(0.0..=8.0).contains(&value) {
388            return Err(format!("precision: {value} is not 0 to 8 decimals"));
389        }
390        *precision = value.round() as u32;
391    }
392    Ok(())
393}
394
395/// The **Sheet dimension** dialog's schema, in the `inputParamsSchema`
396/// vocabulary the shared form engine renders — so the dialog is not
397/// hand-written, exactly like the sheet's and the placement's.
398///
399/// `anchors` is a `reference_selection`: the form draws it as the reference
400/// rows every feature's references are drawn as, its **Select** button opens
401/// the reference picker on the sheet's paper, and each row's ✕ drops that
402/// anchor.
403/// With no `label` its row reads the prettified key, "Anchors".
404///
405/// The last four fields are the tolerance block ([`SheetTolerance`]), the PMI
406/// dimension form's own.
407pub fn sheet_dimension_schema() -> Value {
408    let mut fields = json!({
409        "id": { "type": "string", "default_value": null },
410        "kind": { "type": "options", "label": "Kind", "options": KINDS, "default_value": LINEAR },
411        "alignment": { "type": "options", "label": "Alignment", "options": ALIGNMENTS, "default_value": ALIGNED },
412        "anchors": { "type": "reference_selection", "selectionFilter": ["sheetAnchor"], "multiple": true },
413        "offsetMm": { "type": "number", "label": "Offset (mm)", "default_value": DEFAULT_OFFSET_MM },
414        "precision": { "type": "number", "label": "Precision (decimals)", "default_value": DEFAULT_PRECISION },
415    });
416    SheetTolerance::schema_fields(fields.as_object_mut().expect("an object"));
417    json!({
418        "type": "sheetDimension",
419        "shortName": "SD",
420        "longName": "Sheet dimension",
421        "inputParamsSchema": fields,
422    })
423}
424
425// ===========================================================================
426// Anchors
427// ===========================================================================
428
429/// A parsed anchor reference.
430#[derive(Debug, Clone, PartialEq)]
431pub struct SheetAnchor {
432    /// The PLACED VIEW's id (`SV2`) — which projection the point belongs to.
433    pub view: String,
434    /// [`VERTEX`], [`EDGE`] or [`CIRCLE`].
435    pub kind: String,
436    /// The solid the geometry belongs to.
437    pub solid: String,
438    /// A display vertex's topology id ([`VERTEX`]).
439    pub vertex: u64,
440    /// The kernel edge name ([`EDGE`], [`CIRCLE`]).
441    pub edge: String,
442    /// Arc-length fraction along the edge ([`EDGE`]): 0 and 1 are its ends,
443    /// 0.5 its midpoint.
444    pub fraction: f64,
445}
446
447impl SheetAnchor {
448    pub fn vertex(view: &str, solid: &str, topo_id: u64) -> SheetAnchor {
449        SheetAnchor {
450            view: view.into(),
451            kind: VERTEX.into(),
452            solid: solid.into(),
453            vertex: topo_id,
454            edge: String::new(),
455            fraction: 0.0,
456        }
457    }
458
459    pub fn edge(view: &str, solid: &str, edge: &str, fraction: f64) -> SheetAnchor {
460        SheetAnchor {
461            view: view.into(),
462            kind: EDGE.into(),
463            solid: solid.into(),
464            vertex: 0,
465            edge: edge.into(),
466            fraction,
467        }
468    }
469
470    pub fn circle(view: &str, solid: &str, edge: &str) -> SheetAnchor {
471        SheetAnchor {
472            view: view.into(),
473            kind: CIRCLE.into(),
474            solid: solid.into(),
475            vertex: 0,
476            edge: edge.into(),
477            fraction: 0.0,
478        }
479    }
480
481    /// The reference string: `{placement}:{kind}:{solid}#{entity}`, with an
482    /// edge's fraction after an `@`.
483    ///
484    /// The two separators are chosen so the parse cannot be ambiguous: a
485    /// kernel edge name carries `|` and `[n]` but never a `#` or an `@`, and
486    /// the placement id and the kind are this module's own vocabulary.
487    pub fn to_ref(&self) -> String {
488        match self.kind.as_str() {
489            EDGE => format!(
490                "{}:{}:{}#{}@{}",
491                self.view, self.kind, self.solid, self.edge, self.fraction
492            ),
493            CIRCLE => format!("{}:{}:{}#{}", self.view, self.kind, self.solid, self.edge),
494            _ => format!("{}:{}:{}#{}", self.view, VERTEX, self.solid, self.vertex),
495        }
496    }
497
498    /// Parse a reference string, or say what is wrong with it.
499    pub fn parse(text: &str) -> Result<SheetAnchor, String> {
500        let bad = || format!("'{text}' is not a sheet anchor (expected SV2:vertex:Solid#7)");
501        let mut parts = text.splitn(3, ':');
502        let view = parts.next().unwrap_or_default().trim();
503        let kind = parts.next().ok_or_else(bad)?.trim();
504        let rest = parts.next().ok_or_else(bad)?;
505        if view.is_empty() {
506            return Err(bad());
507        }
508        let (solid, entity) = rest.split_once('#').ok_or_else(bad)?;
509        if solid.is_empty() || entity.is_empty() {
510            return Err(bad());
511        }
512        match kind {
513            VERTEX => {
514                let id: u64 = entity
515                    .parse()
516                    .map_err(|_| format!("'{entity}' is not a vertex id in '{text}'"))?;
517                Ok(SheetAnchor::vertex(view, solid, id))
518            }
519            EDGE => {
520                let (edge, fraction) = entity
521                    .rsplit_once('@')
522                    .ok_or_else(|| format!("'{text}' is an edge anchor with no fraction"))?;
523                let fraction: f64 = fraction
524                    .parse()
525                    .map_err(|_| format!("'{fraction}' is not a fraction in '{text}'"))?;
526                if !fraction.is_finite() || !(0.0..=1.0).contains(&fraction) {
527                    return Err(format!("'{text}': a fraction runs 0 to 1"));
528                }
529                Ok(SheetAnchor::edge(view, solid, edge, fraction))
530            }
531            CIRCLE => Ok(SheetAnchor::circle(view, solid, entity)),
532            other => Err(format!("no anchor kind '{other}' in '{text}'")),
533        }
534    }
535}
536
537// ===========================================================================
538// The drawing
539// ===========================================================================
540
541/// One sheet dimension, laid out in paper millimetres — the sibling of
542/// [`crate::sheets::project::AnnotationDrawing`], drawn in its own group by
543/// both writers.
544#[derive(Debug, Clone, PartialEq, serde::Serialize)]
545pub struct DimensionDrawing {
546    pub id: String,
547    #[serde(rename = "type")]
548    pub kind: String,
549    /// The kernel-formatted value string, in MODEL units — never divided by
550    /// the sheet scale, exactly as a placed annotation's is.
551    pub text: String,
552    /// The measured value in model units (`NaN` when the dimension did not
553    /// resolve).
554    pub value: f64,
555    pub lines: Vec<Vec<[f64; 2]>>,
556    pub texts: Vec<SheetText>,
557    /// Where the value sits — the point the offset drag grabs, and where an
558    /// UNRESOLVED dimension's reason is written.
559    pub label: [f64; 2],
560    #[serde(rename = "offsetMm")]
561    pub offset_mm: f64,
562    /// The PMI dimension whose tolerance block the value is written with —
563    /// empty unless the dimension carries none of its own and its anchors are
564    /// the points a PMI dimension of the placement's view measures (see
565    /// [`inherited`]).
566    #[serde(rename = "toleranceFrom", skip_serializing_if = "String::is_empty")]
567    pub tolerance_from: String,
568    /// Empty when the dimension resolved; why it did not otherwise. A
569    /// dimension with an error is drawn in the unresolved style rather than
570    /// dropped.
571    #[serde(skip_serializing_if = "String::is_empty")]
572    pub error: String,
573}
574
575impl DimensionDrawing {
576    fn refused(dimension: &SheetDimension, label: [f64; 2], error: String) -> DimensionDrawing {
577        DimensionDrawing {
578            id: dimension.id.clone(),
579            kind: dimension.kind.clone(),
580            text: String::new(),
581            value: f64::NAN,
582            lines: Vec::new(),
583            texts: Vec::new(),
584            label,
585            offset_mm: dimension.offset_mm,
586            tolerance_from: String::new(),
587            error,
588        }
589    }
590}
591
592/// Lay out every dimension of `sheet` against the placements already
593/// projected into `views`. `centre` is where an unresolved dimension writes
594/// its reason when it has no anchor left to write it beside.
595pub fn layout_dimensions(
596    dimensions: &[SheetDimension],
597    views: &[ViewDrawing],
598    centre: [f64; 2],
599) -> Vec<DimensionDrawing> {
600    dimensions
601        .iter()
602        .map(|dimension| layout_one(dimension, views, centre))
603        .collect()
604}
605
606fn layout_one(
607    dimension: &SheetDimension,
608    views: &[ViewDrawing],
609    centre: [f64; 2],
610) -> DimensionDrawing {
611    let wanted = dimension.anchor_count();
612    if dimension.anchors.len() != wanted {
613        return DimensionDrawing::refused(
614            dimension,
615            centre,
616            format!(
617                "a {} dimension takes {wanted} anchor{}; it has {}",
618                dimension.kind,
619                if wanted == 1 { "" } else { "s" },
620                dimension.anchors.len()
621            ),
622        );
623    }
624    // Resolve every anchor against the projection, keeping the FIRST failure's
625    // reason — the point of the unresolved lane is to say which anchor went.
626    let mut resolved: Vec<(&ViewDrawing, &AnchorPoint)> = Vec::new();
627    for reference in &dimension.anchors {
628        match resolve_anchor(reference, views) {
629            Ok(found) => resolved.push(found),
630            Err(error) => {
631                let label = resolved.first().map(|(_, a)| a.at).unwrap_or(centre);
632                return DimensionDrawing::refused(dimension, label, error);
633            }
634        }
635    }
636    // This slice dimensions WITHIN one placement: a dimension across two views
637    // measures between two different projections, which is a different
638    // construction and is named as out of scope rather than guessed at.
639    if let [(first, _), rest @ ..] = resolved.as_slice() {
640        if let Some((other, _)) = rest.iter().find(|(view, _)| view.id != first.id) {
641            return DimensionDrawing::refused(
642                dimension,
643                resolved[0].1.at,
644                format!(
645                    "anchors on two placements ('{}' and '{}'); a sheet dimension measures within one view",
646                    first.id, other.id
647                ),
648            );
649        }
650    }
651    let view = resolved[0].0;
652    if view.scale <= 0.0 {
653        return DimensionDrawing::refused(
654            dimension,
655            resolved[0].1.at,
656            format!("placement '{}' has no scale", view.id),
657        );
658    }
659    if dimension.kind.eq_ignore_ascii_case(LINEAR) {
660        linear(dimension, view, resolved[0].1, resolved[1].1)
661    } else if dimension.kind.eq_ignore_ascii_case(ANGULAR) {
662        angular(dimension, view, resolved[0].1, resolved[1].1)
663    } else {
664        radial(dimension, view, resolved[0].1)
665    }
666}
667
668/// The placement and the anchor point a reference names, or why it is lost.
669pub(super) fn resolve_anchor<'a>(
670    reference: &str,
671    views: &'a [ViewDrawing],
672) -> Result<(&'a ViewDrawing, &'a AnchorPoint), String> {
673    let anchor = SheetAnchor::parse(reference)?;
674    let Some(view) = views.iter().find(|view| view.id == anchor.view) else {
675        return Err(format!("'{reference}': no placed view '{}' on this sheet", anchor.view));
676    };
677    if !view.error.is_empty() {
678        return Err(format!("'{reference}': placement '{}' did not project", view.id));
679    }
680    view.anchors
681        .iter()
682        .find(|candidate| candidate.anchor == reference)
683        .map(|candidate| (view, candidate))
684        .ok_or_else(|| match anchor.kind.as_str() {
685            VERTEX => format!(
686                "'{reference}': '{}' has no vertex {} any more",
687                anchor.solid, anchor.vertex
688            ),
689            CIRCLE => format!("'{reference}': '{}' is not a circular edge any more", anchor.edge),
690            _ => format!("'{reference}': '{}' has no edge '{}' any more", anchor.solid, anchor.edge),
691        })
692}
693
694// ---------------------------------------------------------------------------
695// The paper as a layout frame
696// ---------------------------------------------------------------------------
697
698/// Paper millimetres (y DOWN, SVG's frame) → the layout's own 3D frame: x to
699/// the right, y UP, z out of the page.
700fn lift(p: [f64; 2]) -> [f64; 3] {
701    [p[0], -p[1], 0.0]
702}
703
704/// …and back.
705fn drop_to_paper(q: [f64; 3]) -> [f64; 2] {
706    [q[0], -q[1]]
707}
708
709/// A paper DIRECTION into the layout's frame — the same flip as [`lift`], with
710/// no translation to carry.
711fn lift_dir(d: [f64; 2]) -> [f64; 3] {
712    [d[0], -d[1], 0.0]
713}
714
715/// The cap height everything a sheet AUTHORS is drawn at: the placement's own
716/// text size in paper millimetres, so a sheet dimension and an ordinate value
717/// read at the size of the annotations they sit among. A placement with no
718/// text size of its own falls back to 12 pt, the PMI default.
719pub(super) fn text_height(view: &ViewDrawing) -> f64 {
720    if view.text_mm > 1e-9 {
721        view.text_mm
722    } else {
723        12.0 * super::project::MM_PER_POINT
724    }
725}
726
727/// The style a sheet dimension is laid out with: [`text_height`], the
728/// overlay's arrow proportion, and a camera looking straight at the paper.
729fn style(view: &ViewDrawing) -> PmiLayoutStyle {
730    let text_height = text_height(view);
731    PmiLayoutStyle {
732        arrow: text_height * super::project::ARROW_PER_TEXT,
733        text_height,
734        view_dir: [0.0, 0.0, -1.0],
735        view_up: [0.0, 1.0, 0.0],
736        plane: None,
737    }
738}
739
740/// Finish a laid-out dimension: project every piece back onto the paper,
741/// close the arrowheads into outlines (the sheet's contract is lines and
742/// text), and break the lines around the value.
743fn finish(
744    dimension: &SheetDimension,
745    drawn: brep_kernel::PmiPresentation,
746    value: f64,
747    (text, tolerance_from): (String, String),
748    reseat: &[(([f64; 3], [f64; 3]), [f64; 3])],
749) -> DimensionDrawing {
750    let mut lines: Vec<Vec<[f64; 2]>> = Vec::new();
751    for polyline in &drawn.polylines {
752        // An extension line the layout seated on the PROJECTED point is moved
753        // onto the anchor it belongs to and given the layout's own overshoot
754        // past the dimension line — which is the one thing a component
755        // dimension's shared layout cannot know.
756        let seated = reseat.iter().find(|((from, _), _)| {
757            polyline.len() == 2 && near(polyline[0], *from)
758        });
759        match seated {
760            Some(((_, to), true_point)) => lines.push(vec![drop_to_paper(*true_point), drop_to_paper(*to)]),
761            None => lines.push(polyline.iter().map(|p| drop_to_paper(*p)).collect()),
762        }
763    }
764    for arrow in &drawn.arrows {
765        let mut closed: Vec<[f64; 2]> = arrow.iter().map(|p| drop_to_paper(*p)).collect();
766        closed.push(closed[0]);
767        lines.push(closed);
768    }
769    let texts: Vec<SheetText> = drawn
770        .texts
771        .iter()
772        .filter(|run| !run.text.trim().is_empty())
773        .map(|run| SheetText::flat(run.text.clone(), drop_to_paper(run.anchor), run.height))
774        .collect();
775    let label = texts.first().map(|run| run.anchor).unwrap_or(drop_to_paper(drawn.text_anchor));
776    let lines = super::project::break_for_text_boxes(lines, &texts);
777    DimensionDrawing {
778        id: dimension.id.clone(),
779        kind: dimension.kind.clone(),
780        text,
781        value,
782        lines,
783        texts,
784        label,
785        offset_mm: dimension.offset_mm,
786        tolerance_from,
787        error: String::new(),
788    }
789}
790
791// ---------------------------------------------------------------------------
792// The value's text, and the tolerance a dimension inherits
793// ---------------------------------------------------------------------------
794
795/// The dimension's value text — the kernel's [`format_dimension`] with the
796/// block the dimension carries, or else the one it inherits — and the PMI
797/// dimension it inherited from (empty when it did not).
798fn value_text(
799    dimension: &SheetDimension,
800    view: &ViewDrawing,
801    value: f64,
802    (prefix, suffix): (&str, &str),
803    source: impl Fn(&Measures) -> bool,
804) -> (String, String) {
805    let (block, reference, from) = match inherited(dimension, view, source) {
806        Some(found) => (found.block, found.reference, found.id.clone()),
807        None => (dimension.tolerance.block(), dimension.tolerance.reference, String::new()),
808    };
809    (format_dimension(value, dimension.precision as usize, &block, reference, prefix, suffix), from)
810}
811
812/// The PMI dimension of the placement's view a sheet dimension INHERITS its
813/// tolerance from: none when the sheet dimension carries a block or the
814/// reference flag of its own (its own always wins), else the first of the
815/// view's [`super::project::ToleranceSource`]s that measures what `source`
816/// accepts — the same points, in this view.
817///
818/// "The same point" is the CROSSING FLOOR (`pcurve_consistency` times the
819/// placement's scale, [`super::hlr::Floors`]): two projected points closer
820/// than it are one point to the hidden-line pass, and so they are here.
821pub fn inherited<'a>(
822    dimension: &SheetDimension,
823    view: &'a ViewDrawing,
824    source: impl Fn(&Measures) -> bool,
825) -> Option<&'a super::project::ToleranceSource> {
826    if !dimension.tolerance.is_plain() {
827        return None;
828    }
829    view.tolerances.iter().find(|candidate| source(&candidate.measures))
830}
831
832/// Whether two paper points are one to the placement's crossing floor.
833fn same_point(view: &ViewDrawing, a: [f64; 2], b: [f64; 2]) -> bool {
834    length2([a[0] - b[0], a[1] - b[1]]) <= super::hlr::Floors::of(view.scale).crossing_mm
835}
836
837fn near(a: [f64; 3], b: [f64; 3]) -> bool {
838    (0..3).all(|i| (a[i] - b[i]).abs() < 1e-9)
839}
840
841fn length2(v: [f64; 2]) -> f64 {
842    (v[0] * v[0] + v[1] * v[1]).sqrt()
843}
844
845/// The 2D cross product — the signed area of the parallelogram `a`, `b` span.
846fn cross2(a: [f64; 2], b: [f64; 2]) -> f64 {
847    a[0] * b[1] - a[1] * b[0]
848}
849
850// ---------------------------------------------------------------------------
851// Linear
852// ---------------------------------------------------------------------------
853
854fn linear(
855    dimension: &SheetDimension,
856    view: &ViewDrawing,
857    first: &AnchorPoint,
858    second: &AnchorPoint,
859) -> DimensionDrawing {
860    let (a, b) = (lift(first.at), lift(second.at));
861    let alignment = dimension.alignment.as_str();
862    // The measured direction, canonicalised so the offset's SIDE does not
863    // depend on the order the two anchors were picked in: rightward, or
864    // downward when the measurement is vertical.
865    let raw = [b[0] - a[0], b[1] - a[1]];
866    let flip = if alignment.eq_ignore_ascii_case(HORIZONTAL) {
867        raw[0] < 0.0
868    } else if alignment.eq_ignore_ascii_case(VERTICAL) {
869        raw[1] > 0.0
870    } else {
871        raw[0] < 0.0 || (raw[0] == 0.0 && raw[1] > 0.0)
872    };
873    let (a, b) = if flip { (b, a) } else { (a, b) };
874    let span = [b[0] - a[0], b[1] - a[1]];
875    let d = match alignment {
876        _ if alignment.eq_ignore_ascii_case(HORIZONTAL) => [1.0, 0.0],
877        _ if alignment.eq_ignore_ascii_case(VERTICAL) => [0.0, -1.0],
878        _ => {
879            let len = length2(span);
880            if len < 1e-9 {
881                return DimensionDrawing::refused(
882                    dimension,
883                    first.at,
884                    "the two anchors project to the same point".into(),
885                );
886            }
887            [span[0] / len, span[1] / len]
888        }
889    };
890    // The dimension line's own normal, the side the offset moves it to.
891    let n = [-d[1], d[0]];
892    let value_mm = (span[0] * d[0] + span[1] * d[1]).abs();
893    if value_mm < 1e-9 {
894        return DimensionDrawing::refused(
895            dimension,
896            first.at,
897            format!("the two anchors have no {} separation", dimension.alignment),
898        );
899    }
900    // The kernel's own X / Y / Z component rule, read in the sheet's axes: the
901    // layout draws the dimension line along `b - a`, so a component dimension
902    // measures a PROJECTED pair. The common cross-coordinate is the anchor
903    // FARTHER from the side the offset moves to, so the dimension line clears
904    // both anchors rather than landing between them.
905    let (ca, cb) = (a[0] * n[0] + a[1] * n[1], b[0] * n[0] + b[1] * n[1]);
906    let base = if dimension.offset_mm >= 0.0 { ca.min(cb) } else { ca.max(cb) };
907    let onto = |p: [f64; 3], c: f64| -> [f64; 3] {
908        let along = p[0] * d[0] + p[1] * d[1];
909        [along * d[0] + c * n[0], along * d[1] + c * n[1], 0.0]
910    };
911    let (pa, pb) = (onto(a, base), onto(b, base));
912    // Paper distance determines layout, but dividing it by scale cannot
913    // recover the depth lost to projection. Aligned dimensions measure the
914    // original 3D pair; horizontal/vertical dimensions measure view components.
915    let aligned = alignment.eq_ignore_ascii_case(ALIGNED);
916    let value = if aligned {
917        first.model.iter().zip(second.model).map(|(a, b)| (b - a).powi(2)).sum::<f64>().sqrt()
918    } else {
919        value_mm / view.scale
920    };
921    // A free PMI dimension between these very two points hands its block on —
922    // to an ALIGNED sheet dimension only: a horizontal or vertical one measures
923    // a component, which is a different number.
924    let text = value_text(dimension, view, value, ("", ""), |measures| match measures {
925        Measures::Linear { a, b } => {
926            aligned
927                && ((same_point(view, *a, first.at) && same_point(view, *b, second.at))
928                    || (same_point(view, *a, second.at) && same_point(view, *b, first.at)))
929        }
930        _ => false,
931    });
932    let style = style(view);
933    // Where the value sits along the line. Centred when its box and both
934    // arrowheads fit between the extension lines; otherwise just PAST the far
935    // one, one arrowhead clear of it — the layout runs the dimension line on
936    // to the value's station — because a value centred on a line too short for
937    // it would cover the line and break both arrowheads out of the drawing. A
938    // toleranced value is two or three times the plain number's width, which
939    // is when this starts to matter.
940    let half = super::project::text_half_width(&text.0, style.text_height);
941    let along = if 2.0 * (half + style.arrow) <= value_mm {
942        [(pa[0] + pb[0]) * 0.5, (pa[1] + pb[1]) * 0.5]
943    } else {
944        let past = style.arrow + half;
945        [pb[0] + d[0] * past, pb[1] + d[1] * past]
946    };
947    let label = [along[0] + n[0] * dimension.offset_mm, along[1] + n[1] * dimension.offset_mm, 0.0];
948    let drawn = pmi_present(
949        &PmiGeometry::Linear { a: pa, b: pb, component: None },
950        label,
951        &text.0,
952        &style,
953    );
954    // The layout's extension lines start at the pair it MEASURED. Re-seat each
955    // on its true anchor and run it to the layout's own overshoot past the
956    // dimension line — computed from the anchor's own side, so an anchor
957    // BEYOND the line still reaches past it rather than stopping short.
958    let overshoot = style.arrow * 0.6;
959    let reseat: Vec<(([f64; 3], [f64; 3]), [f64; 3])> = [(pa, a), (pb, b)]
960        .iter()
961        .filter_map(|(projected, truth)| {
962            let foot = [
963                projected[0] + n[0] * dimension.offset_mm,
964                projected[1] + n[1] * dimension.offset_mm,
965                0.0,
966            ];
967            let away = [foot[0] - truth[0], foot[1] - truth[1]];
968            let len = length2(away);
969            if len < 1e-9 {
970                return None;
971            }
972            let end = [
973                foot[0] + away[0] / len * overshoot,
974                foot[1] + away[1] / len * overshoot,
975                0.0,
976            ];
977            Some(((*projected, end), *truth))
978        })
979        .collect();
980    finish(dimension, drawn, value, text, &reseat)
981}
982
983// ---------------------------------------------------------------------------
984// Radial and diametral
985// ---------------------------------------------------------------------------
986
987fn radial(dimension: &SheetDimension, view: &ViewDrawing, anchor: &AnchorPoint) -> DimensionDrawing {
988    let (Some(radius), Some(u), Some(v)) = (anchor.radius, anchor.u, anchor.v) else {
989        return DimensionDrawing::refused(
990            dimension,
991            anchor.at,
992            format!("'{}' is not a circular edge", anchor.anchor),
993        );
994    };
995    let diameter = dimension.kind.eq_ignore_ascii_case(DIAMETRAL);
996    // The projected circle is an ELLIPSE — `u` and `v` are the paper images of
997    // its two conjugate radii — so "the radius" the leader reaches is the
998    // ellipse's extent along the leader's own direction. An ellipse is centrally
999    // symmetric, so the diameter line through the centre reaches the same
1000    // distance on both sides, which is exactly what the shared layout draws.
1001    let dir = RADIAL_DIR;
1002    let (u, v) = ([u[0], -u[1]], [v[0], -v[1]]);
1003    let theta = (-cross2(u, dir)).atan2(cross2(v, dir));
1004    let at = |t: f64| [u[0] * t.cos() + v[0] * t.sin(), u[1] * t.cos() + v[1] * t.sin()];
1005    let mut point = at(theta);
1006    if point[0] * dir[0] + point[1] * dir[1] < 0.0 {
1007        point = at(theta + std::f64::consts::PI);
1008    }
1009    let paper_radius = length2(point);
1010    if !(paper_radius > 1e-9) {
1011        return DimensionDrawing::refused(
1012            dimension,
1013            anchor.at,
1014            format!("'{}' projects edge-on: its circle has no radius on the paper", anchor.anchor),
1015        );
1016    }
1017    let centre = lift(anchor.at);
1018    let reach = paper_radius + dimension.offset_mm;
1019    let label = [centre[0] + dir[0] * reach, centre[1] + dir[1] * reach, 0.0];
1020    let value = if diameter { radius * 2.0 } else { radius };
1021    // A PMI radial (or diametral) dimension of this circle — the same centre on
1022    // the paper and the same MODEL radius to the B-rep bar — hands its block on
1023    // to the sheet dimension of the same kind.
1024    let bar = super::hlr::Floors::of(view.scale).bar;
1025    let text = value_text(
1026        dimension,
1027        view,
1028        value,
1029        (if diameter { "\u{2300}" } else { "R" }, ""),
1030        |measures| match measures {
1031            Measures::Radial { centre, radius: measured, diameter: pmi_diameter } => {
1032                *pmi_diameter == diameter && same_point(view, *centre, anchor.at) && (measured - radius).abs() <= bar
1033            }
1034            _ => false,
1035        },
1036    );
1037    let style = style(view);
1038    let drawn = pmi_present(
1039        &PmiGeometry::Radial {
1040            center: centre,
1041            axis: [0.0, 0.0, 1.0],
1042            radius: paper_radius,
1043            diameter,
1044            sphere: false,
1045        },
1046        label,
1047        &text.0,
1048        &style,
1049    );
1050    finish(dimension, drawn, value, text, &[])
1051}
1052
1053
1054// ---------------------------------------------------------------------------
1055// Angular
1056// ---------------------------------------------------------------------------
1057
1058/// The angle between two projected STRAIGHT edges, as an arc with tangent
1059/// arrowheads and the value in degrees.
1060///
1061/// Both anchors are `edge` candidates, and each carries its edge's own unit
1062/// PAPER direction ([`AnchorPoint::dir`]) — the direction as THIS view projects
1063/// it, so a foreshortened angle reads its projected value rather than the
1064/// model's. That is the drawing convention and it is what an orthographic view
1065/// can honestly say: the true angle between two skew or tilted edges is not a
1066/// property of this projection.
1067///
1068/// **Which angle.** Two lines crossing make four angles, and the pair of RAYS
1069/// this measures is chosen by the anchors themselves: each ray leaves the
1070/// vertex toward the anchor POINT that was picked, so picking an edge's near
1071/// end and its far end pick supplementary angles. The value is therefore always
1072/// the angle between the two picked rays, strictly between 0° and 180°.
1073///
1074/// `offsetMm` is the arc's radius from the vertex, measured along the bisector
1075/// of the two rays — the one number a single drag can move, exactly as a radial
1076/// dimension's offset is.
1077fn angular(
1078    dimension: &SheetDimension,
1079    view: &ViewDrawing,
1080    first: &AnchorPoint,
1081    second: &AnchorPoint,
1082) -> DimensionDrawing {
1083    let direction = |anchor: &AnchorPoint| -> Result<[f64; 2], String> {
1084        anchor.dir.ok_or_else(|| {
1085            if anchor.kind == EDGE {
1086                format!(
1087                    "'{}' is not a straight edge in this view; an angular dimension measures between two straight edges",
1088                    anchor.anchor
1089                )
1090            } else {
1091                format!("'{}' is a {} anchor; an angular dimension measures between two EDGES", anchor.anchor, anchor.kind)
1092            }
1093        })
1094    };
1095    let (da, db) = match (direction(first), direction(second)) {
1096        (Ok(a), Ok(b)) => (a, b),
1097        (Err(error), _) | (_, Err(error)) => {
1098            return DimensionDrawing::refused(dimension, first.at, error)
1099        }
1100    };
1101    // Where the two lines cross — the arc's centre and the vertex the
1102    // extension lines run out from. Parallel lines never do, and there is no
1103    // angle to draw rather than a very distant one.
1104    let denominator = cross2(da, db);
1105    if denominator.abs() < 1e-9 {
1106        return DimensionDrawing::refused(
1107            dimension,
1108            first.at,
1109            format!(
1110                "'{}' and '{}' are parallel in this view; they make no angle",
1111                first.anchor, second.anchor
1112            ),
1113        );
1114    }
1115    let between = [second.at[0] - first.at[0], second.at[1] - first.at[1]];
1116    let t = cross2(between, db) / denominator;
1117    let vertex = [first.at[0] + da[0] * t, first.at[1] + da[1] * t];
1118    // Each ray points from the vertex at the anchor that was picked. An anchor
1119    // sitting ON the vertex — an edge picked at the very end the other one
1120    // meets — has no side to name, so the edge's own direction stands in.
1121    let ray = |anchor: &AnchorPoint, fallback: [f64; 2]| -> [f64; 2] {
1122        let away = [anchor.at[0] - vertex[0], anchor.at[1] - vertex[1]];
1123        let length = length2(away);
1124        if length < 1e-9 {
1125            fallback
1126        } else {
1127            [away[0] / length, away[1] / length]
1128        }
1129    };
1130    let (ra, rb) = (ray(first, da), ray(second, db));
1131    let degrees = cross2(ra, rb).abs().atan2(ra[0] * rb[0] + ra[1] * rb[1]).to_degrees();
1132    if !(degrees > 1e-6) {
1133        return DimensionDrawing::refused(
1134            dimension,
1135            first.at,
1136            format!(
1137                "'{}' and '{}' leave the vertex the same way; they make no angle",
1138                first.anchor, second.anchor
1139            ),
1140        );
1141    }
1142    // Into the layout's own frame (x right, y UP, z out of the page), where the
1143    // arc sweeps `degrees` from `dir_a` about +z RIGHT-HANDED — so the pair is
1144    // ordered by that sweep rather than by the order the anchors were picked.
1145    let (la, lb) = (lift_dir(ra), lift_dir(rb));
1146    let (dir_a, dir_b) = if cross2([la[0], la[1]], [lb[0], lb[1]]) >= 0.0 {
1147        (la, lb)
1148    } else {
1149        (lb, la)
1150    };
1151    let bisector = [dir_a[0] + dir_b[0], dir_a[1] + dir_b[1]];
1152    let length = length2([bisector[0], bisector[1]]);
1153    // `degrees` is strictly below 180, so the two rays never cancel.
1154    let bisector = [bisector[0] / length, bisector[1] / length, 0.0];
1155    let centre = lift(vertex);
1156    let label = [
1157        centre[0] + bisector[0] * dimension.offset_mm,
1158        centre[1] + bisector[1] * dimension.offset_mm,
1159        0.0,
1160    ];
1161    // The kernel's own angle string: the number at this sheet's precision with
1162    // a degree sign, the same text a PMI angle annotation carries.
1163    // An angle inherits nothing: the sheet reads the angle AS THIS VIEW
1164    // PROJECTS IT, which is the model's angle only when the view looks down
1165    // the angle's axis, and a tolerance on one is not a tolerance on the other.
1166    let text = value_text(dimension, view, degrees, ("", "\u{00B0}"), |_| false);
1167    let style = style(view);
1168    let drawn = pmi_present(
1169        &PmiGeometry::Angular {
1170            vertex: centre,
1171            dir_a,
1172            dir_b,
1173            axis: [0.0, 0.0, 1.0],
1174            degrees,
1175        },
1176        label,
1177        &text.0,
1178        &style,
1179    );
1180    finish(dimension, drawn, degrees, text, &[])
1181}
1182
1183