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        if let Some(offset) = params.get("offsetMm").and_then(Value::as_f64) {
369            if !offset.is_finite() {
370                return Err(format!("offsetMm: {offset} is not a length"));
371            }
372            self.offset_mm = offset;
373        }
374        if let Some(precision) = params.get("precision").and_then(Value::as_f64) {
375            if !precision.is_finite() || !(0.0..=8.0).contains(&precision) {
376                return Err(format!("precision: {precision} is not 0 to 8 decimals"));
377            }
378            self.precision = precision.round() as u32;
379        }
380        self.tolerance.apply(params)
381    }
382}
383
384/// The **Sheet dimension** dialog's schema, in the `inputParamsSchema`
385/// vocabulary the shared form engine renders — so the dialog is not
386/// hand-written, exactly like the sheet's and the placement's.
387///
388/// `anchors` is a `reference_selection`: the form draws it as the reference
389/// rows every feature's references are drawn as, its **Select** button opens
390/// the reference picker on the sheet's paper, and each row's ✕ drops that
391/// anchor.
392/// With no `label` its row reads the prettified key, "Anchors".
393///
394/// The last four fields are the tolerance block ([`SheetTolerance`]), the PMI
395/// dimension form's own.
396pub fn sheet_dimension_schema() -> Value {
397    let mut fields = json!({
398        "id": { "type": "string", "default_value": null },
399        "kind": { "type": "options", "label": "Kind", "options": KINDS, "default_value": LINEAR },
400        "alignment": { "type": "options", "label": "Alignment", "options": ALIGNMENTS, "default_value": ALIGNED },
401        "anchors": { "type": "reference_selection", "selectionFilter": ["sheetAnchor"], "multiple": true },
402        "offsetMm": { "type": "number", "label": "Offset (mm)", "default_value": DEFAULT_OFFSET_MM },
403        "precision": { "type": "number", "label": "Precision (decimals)", "default_value": DEFAULT_PRECISION },
404    });
405    SheetTolerance::schema_fields(fields.as_object_mut().expect("an object"));
406    json!({
407        "type": "sheetDimension",
408        "shortName": "SD",
409        "longName": "Sheet dimension",
410        "inputParamsSchema": fields,
411    })
412}
413
414// ===========================================================================
415// Anchors
416// ===========================================================================
417
418/// A parsed anchor reference.
419#[derive(Debug, Clone, PartialEq)]
420pub struct SheetAnchor {
421    /// The PLACED VIEW's id (`SV2`) — which projection the point belongs to.
422    pub view: String,
423    /// [`VERTEX`], [`EDGE`] or [`CIRCLE`].
424    pub kind: String,
425    /// The solid the geometry belongs to.
426    pub solid: String,
427    /// A display vertex's topology id ([`VERTEX`]).
428    pub vertex: u64,
429    /// The kernel edge name ([`EDGE`], [`CIRCLE`]).
430    pub edge: String,
431    /// Arc-length fraction along the edge ([`EDGE`]): 0 and 1 are its ends,
432    /// 0.5 its midpoint.
433    pub fraction: f64,
434}
435
436impl SheetAnchor {
437    pub fn vertex(view: &str, solid: &str, topo_id: u64) -> SheetAnchor {
438        SheetAnchor {
439            view: view.into(),
440            kind: VERTEX.into(),
441            solid: solid.into(),
442            vertex: topo_id,
443            edge: String::new(),
444            fraction: 0.0,
445        }
446    }
447
448    pub fn edge(view: &str, solid: &str, edge: &str, fraction: f64) -> SheetAnchor {
449        SheetAnchor {
450            view: view.into(),
451            kind: EDGE.into(),
452            solid: solid.into(),
453            vertex: 0,
454            edge: edge.into(),
455            fraction,
456        }
457    }
458
459    pub fn circle(view: &str, solid: &str, edge: &str) -> SheetAnchor {
460        SheetAnchor {
461            view: view.into(),
462            kind: CIRCLE.into(),
463            solid: solid.into(),
464            vertex: 0,
465            edge: edge.into(),
466            fraction: 0.0,
467        }
468    }
469
470    /// The reference string: `{placement}:{kind}:{solid}#{entity}`, with an
471    /// edge's fraction after an `@`.
472    ///
473    /// The two separators are chosen so the parse cannot be ambiguous: a
474    /// kernel edge name carries `|` and `[n]` but never a `#` or an `@`, and
475    /// the placement id and the kind are this module's own vocabulary.
476    pub fn to_ref(&self) -> String {
477        match self.kind.as_str() {
478            EDGE => format!(
479                "{}:{}:{}#{}@{}",
480                self.view, self.kind, self.solid, self.edge, self.fraction
481            ),
482            CIRCLE => format!("{}:{}:{}#{}", self.view, self.kind, self.solid, self.edge),
483            _ => format!("{}:{}:{}#{}", self.view, VERTEX, self.solid, self.vertex),
484        }
485    }
486
487    /// Parse a reference string, or say what is wrong with it.
488    pub fn parse(text: &str) -> Result<SheetAnchor, String> {
489        let bad = || format!("'{text}' is not a sheet anchor (expected SV2:vertex:Solid#7)");
490        let mut parts = text.splitn(3, ':');
491        let view = parts.next().unwrap_or_default().trim();
492        let kind = parts.next().ok_or_else(bad)?.trim();
493        let rest = parts.next().ok_or_else(bad)?;
494        if view.is_empty() {
495            return Err(bad());
496        }
497        let (solid, entity) = rest.split_once('#').ok_or_else(bad)?;
498        if solid.is_empty() || entity.is_empty() {
499            return Err(bad());
500        }
501        match kind {
502            VERTEX => {
503                let id: u64 = entity
504                    .parse()
505                    .map_err(|_| format!("'{entity}' is not a vertex id in '{text}'"))?;
506                Ok(SheetAnchor::vertex(view, solid, id))
507            }
508            EDGE => {
509                let (edge, fraction) = entity
510                    .rsplit_once('@')
511                    .ok_or_else(|| format!("'{text}' is an edge anchor with no fraction"))?;
512                let fraction: f64 = fraction
513                    .parse()
514                    .map_err(|_| format!("'{fraction}' is not a fraction in '{text}'"))?;
515                if !fraction.is_finite() || !(0.0..=1.0).contains(&fraction) {
516                    return Err(format!("'{text}': a fraction runs 0 to 1"));
517                }
518                Ok(SheetAnchor::edge(view, solid, edge, fraction))
519            }
520            CIRCLE => Ok(SheetAnchor::circle(view, solid, entity)),
521            other => Err(format!("no anchor kind '{other}' in '{text}'")),
522        }
523    }
524}
525
526// ===========================================================================
527// The drawing
528// ===========================================================================
529
530/// One sheet dimension, laid out in paper millimetres — the sibling of
531/// [`crate::sheets::project::AnnotationDrawing`], drawn in its own group by
532/// both writers.
533#[derive(Debug, Clone, PartialEq, serde::Serialize)]
534pub struct DimensionDrawing {
535    pub id: String,
536    #[serde(rename = "type")]
537    pub kind: String,
538    /// The kernel-formatted value string, in MODEL units — never divided by
539    /// the sheet scale, exactly as a placed annotation's is.
540    pub text: String,
541    /// The measured value in model units (`NaN` when the dimension did not
542    /// resolve).
543    pub value: f64,
544    pub lines: Vec<Vec<[f64; 2]>>,
545    pub texts: Vec<SheetText>,
546    /// Where the value sits — the point the offset drag grabs, and where an
547    /// UNRESOLVED dimension's reason is written.
548    pub label: [f64; 2],
549    #[serde(rename = "offsetMm")]
550    pub offset_mm: f64,
551    /// The PMI dimension whose tolerance block the value is written with —
552    /// empty unless the dimension carries none of its own and its anchors are
553    /// the points a PMI dimension of the placement's view measures (see
554    /// [`inherited`]).
555    #[serde(rename = "toleranceFrom", skip_serializing_if = "String::is_empty")]
556    pub tolerance_from: String,
557    /// Empty when the dimension resolved; why it did not otherwise. A
558    /// dimension with an error is drawn in the unresolved style rather than
559    /// dropped.
560    #[serde(skip_serializing_if = "String::is_empty")]
561    pub error: String,
562}
563
564impl DimensionDrawing {
565    fn refused(dimension: &SheetDimension, label: [f64; 2], error: String) -> DimensionDrawing {
566        DimensionDrawing {
567            id: dimension.id.clone(),
568            kind: dimension.kind.clone(),
569            text: String::new(),
570            value: f64::NAN,
571            lines: Vec::new(),
572            texts: Vec::new(),
573            label,
574            offset_mm: dimension.offset_mm,
575            tolerance_from: String::new(),
576            error,
577        }
578    }
579}
580
581/// Lay out every dimension of `sheet` against the placements already
582/// projected into `views`. `centre` is where an unresolved dimension writes
583/// its reason when it has no anchor left to write it beside.
584pub fn layout_dimensions(
585    dimensions: &[SheetDimension],
586    views: &[ViewDrawing],
587    centre: [f64; 2],
588) -> Vec<DimensionDrawing> {
589    dimensions
590        .iter()
591        .map(|dimension| layout_one(dimension, views, centre))
592        .collect()
593}
594
595fn layout_one(
596    dimension: &SheetDimension,
597    views: &[ViewDrawing],
598    centre: [f64; 2],
599) -> DimensionDrawing {
600    let wanted = dimension.anchor_count();
601    if dimension.anchors.len() != wanted {
602        return DimensionDrawing::refused(
603            dimension,
604            centre,
605            format!(
606                "a {} dimension takes {wanted} anchor{}; it has {}",
607                dimension.kind,
608                if wanted == 1 { "" } else { "s" },
609                dimension.anchors.len()
610            ),
611        );
612    }
613    // Resolve every anchor against the projection, keeping the FIRST failure's
614    // reason — the point of the unresolved lane is to say which anchor went.
615    let mut resolved: Vec<(&ViewDrawing, &AnchorPoint)> = Vec::new();
616    for reference in &dimension.anchors {
617        match resolve_anchor(reference, views) {
618            Ok(found) => resolved.push(found),
619            Err(error) => {
620                let label = resolved.first().map(|(_, a)| a.at).unwrap_or(centre);
621                return DimensionDrawing::refused(dimension, label, error);
622            }
623        }
624    }
625    // This slice dimensions WITHIN one placement: a dimension across two views
626    // measures between two different projections, which is a different
627    // construction and is named as out of scope rather than guessed at.
628    if let [(first, _), rest @ ..] = resolved.as_slice() {
629        if let Some((other, _)) = rest.iter().find(|(view, _)| view.id != first.id) {
630            return DimensionDrawing::refused(
631                dimension,
632                resolved[0].1.at,
633                format!(
634                    "anchors on two placements ('{}' and '{}'); a sheet dimension measures within one view",
635                    first.id, other.id
636                ),
637            );
638        }
639    }
640    let view = resolved[0].0;
641    if view.scale <= 0.0 {
642        return DimensionDrawing::refused(
643            dimension,
644            resolved[0].1.at,
645            format!("placement '{}' has no scale", view.id),
646        );
647    }
648    if dimension.kind.eq_ignore_ascii_case(LINEAR) {
649        linear(dimension, view, resolved[0].1, resolved[1].1)
650    } else if dimension.kind.eq_ignore_ascii_case(ANGULAR) {
651        angular(dimension, view, resolved[0].1, resolved[1].1)
652    } else {
653        radial(dimension, view, resolved[0].1)
654    }
655}
656
657/// The placement and the anchor point a reference names, or why it is lost.
658pub(super) fn resolve_anchor<'a>(
659    reference: &str,
660    views: &'a [ViewDrawing],
661) -> Result<(&'a ViewDrawing, &'a AnchorPoint), String> {
662    let anchor = SheetAnchor::parse(reference)?;
663    let Some(view) = views.iter().find(|view| view.id == anchor.view) else {
664        return Err(format!("'{reference}': no placed view '{}' on this sheet", anchor.view));
665    };
666    if !view.error.is_empty() {
667        return Err(format!("'{reference}': placement '{}' did not project", view.id));
668    }
669    view.anchors
670        .iter()
671        .find(|candidate| candidate.anchor == reference)
672        .map(|candidate| (view, candidate))
673        .ok_or_else(|| match anchor.kind.as_str() {
674            VERTEX => format!(
675                "'{reference}': '{}' has no vertex {} any more",
676                anchor.solid, anchor.vertex
677            ),
678            CIRCLE => format!("'{reference}': '{}' is not a circular edge any more", anchor.edge),
679            _ => format!("'{reference}': '{}' has no edge '{}' any more", anchor.solid, anchor.edge),
680        })
681}
682
683// ---------------------------------------------------------------------------
684// The paper as a layout frame
685// ---------------------------------------------------------------------------
686
687/// Paper millimetres (y DOWN, SVG's frame) → the layout's own 3D frame: x to
688/// the right, y UP, z out of the page.
689fn lift(p: [f64; 2]) -> [f64; 3] {
690    [p[0], -p[1], 0.0]
691}
692
693/// …and back.
694fn drop_to_paper(q: [f64; 3]) -> [f64; 2] {
695    [q[0], -q[1]]
696}
697
698/// A paper DIRECTION into the layout's frame — the same flip as [`lift`], with
699/// no translation to carry.
700fn lift_dir(d: [f64; 2]) -> [f64; 3] {
701    [d[0], -d[1], 0.0]
702}
703
704/// The cap height everything a sheet AUTHORS is drawn at: the placement's own
705/// text size in paper millimetres, so a sheet dimension and an ordinate value
706/// read at the size of the annotations they sit among. A placement with no
707/// text size of its own falls back to 12 pt, the PMI default.
708pub(super) fn text_height(view: &ViewDrawing) -> f64 {
709    if view.text_mm > 1e-9 {
710        view.text_mm
711    } else {
712        12.0 * super::project::MM_PER_POINT
713    }
714}
715
716/// The style a sheet dimension is laid out with: [`text_height`], the
717/// overlay's arrow proportion, and a camera looking straight at the paper.
718fn style(view: &ViewDrawing) -> PmiLayoutStyle {
719    let text_height = text_height(view);
720    PmiLayoutStyle {
721        arrow: text_height * super::project::ARROW_PER_TEXT,
722        text_height,
723        view_dir: [0.0, 0.0, -1.0],
724        view_up: [0.0, 1.0, 0.0],
725        plane: None,
726    }
727}
728
729/// Finish a laid-out dimension: project every piece back onto the paper,
730/// close the arrowheads into outlines (the sheet's contract is lines and
731/// text), and break the lines around the value.
732fn finish(
733    dimension: &SheetDimension,
734    drawn: brep_kernel::PmiPresentation,
735    value: f64,
736    (text, tolerance_from): (String, String),
737    reseat: &[(([f64; 3], [f64; 3]), [f64; 3])],
738) -> DimensionDrawing {
739    let mut lines: Vec<Vec<[f64; 2]>> = Vec::new();
740    for polyline in &drawn.polylines {
741        // An extension line the layout seated on the PROJECTED point is moved
742        // onto the anchor it belongs to and given the layout's own overshoot
743        // past the dimension line — which is the one thing a component
744        // dimension's shared layout cannot know.
745        let seated = reseat.iter().find(|((from, _), _)| {
746            polyline.len() == 2 && near(polyline[0], *from)
747        });
748        match seated {
749            Some(((_, to), true_point)) => lines.push(vec![drop_to_paper(*true_point), drop_to_paper(*to)]),
750            None => lines.push(polyline.iter().map(|p| drop_to_paper(*p)).collect()),
751        }
752    }
753    for arrow in &drawn.arrows {
754        let mut closed: Vec<[f64; 2]> = arrow.iter().map(|p| drop_to_paper(*p)).collect();
755        closed.push(closed[0]);
756        lines.push(closed);
757    }
758    let texts: Vec<SheetText> = drawn
759        .texts
760        .iter()
761        .filter(|run| !run.text.trim().is_empty())
762        .map(|run| SheetText::flat(run.text.clone(), drop_to_paper(run.anchor), run.height))
763        .collect();
764    let label = texts.first().map(|run| run.anchor).unwrap_or(drop_to_paper(drawn.text_anchor));
765    let lines = super::project::break_for_text_boxes(lines, &texts);
766    DimensionDrawing {
767        id: dimension.id.clone(),
768        kind: dimension.kind.clone(),
769        text,
770        value,
771        lines,
772        texts,
773        label,
774        offset_mm: dimension.offset_mm,
775        tolerance_from,
776        error: String::new(),
777    }
778}
779
780// ---------------------------------------------------------------------------
781// The value's text, and the tolerance a dimension inherits
782// ---------------------------------------------------------------------------
783
784/// The dimension's value text — the kernel's [`format_dimension`] with the
785/// block the dimension carries, or else the one it inherits — and the PMI
786/// dimension it inherited from (empty when it did not).
787fn value_text(
788    dimension: &SheetDimension,
789    view: &ViewDrawing,
790    value: f64,
791    (prefix, suffix): (&str, &str),
792    source: impl Fn(&Measures) -> bool,
793) -> (String, String) {
794    let (block, reference, from) = match inherited(dimension, view, source) {
795        Some(found) => (found.block, found.reference, found.id.clone()),
796        None => (dimension.tolerance.block(), dimension.tolerance.reference, String::new()),
797    };
798    (format_dimension(value, dimension.precision as usize, &block, reference, prefix, suffix), from)
799}
800
801/// The PMI dimension of the placement's view a sheet dimension INHERITS its
802/// tolerance from: none when the sheet dimension carries a block or the
803/// reference flag of its own (its own always wins), else the first of the
804/// view's [`super::project::ToleranceSource`]s that measures what `source`
805/// accepts — the same points, in this view.
806///
807/// "The same point" is the CROSSING FLOOR (`pcurve_consistency` times the
808/// placement's scale, [`super::hlr::Floors`]): two projected points closer
809/// than it are one point to the hidden-line pass, and so they are here.
810pub fn inherited<'a>(
811    dimension: &SheetDimension,
812    view: &'a ViewDrawing,
813    source: impl Fn(&Measures) -> bool,
814) -> Option<&'a super::project::ToleranceSource> {
815    if !dimension.tolerance.is_plain() {
816        return None;
817    }
818    view.tolerances.iter().find(|candidate| source(&candidate.measures))
819}
820
821/// Whether two paper points are one to the placement's crossing floor.
822fn same_point(view: &ViewDrawing, a: [f64; 2], b: [f64; 2]) -> bool {
823    length2([a[0] - b[0], a[1] - b[1]]) <= super::hlr::Floors::of(view.scale).crossing_mm
824}
825
826fn near(a: [f64; 3], b: [f64; 3]) -> bool {
827    (0..3).all(|i| (a[i] - b[i]).abs() < 1e-9)
828}
829
830fn length2(v: [f64; 2]) -> f64 {
831    (v[0] * v[0] + v[1] * v[1]).sqrt()
832}
833
834/// The 2D cross product — the signed area of the parallelogram `a`, `b` span.
835fn cross2(a: [f64; 2], b: [f64; 2]) -> f64 {
836    a[0] * b[1] - a[1] * b[0]
837}
838
839// ---------------------------------------------------------------------------
840// Linear
841// ---------------------------------------------------------------------------
842
843fn linear(
844    dimension: &SheetDimension,
845    view: &ViewDrawing,
846    first: &AnchorPoint,
847    second: &AnchorPoint,
848) -> DimensionDrawing {
849    let (a, b) = (lift(first.at), lift(second.at));
850    let alignment = dimension.alignment.as_str();
851    // The measured direction, canonicalised so the offset's SIDE does not
852    // depend on the order the two anchors were picked in: rightward, or
853    // downward when the measurement is vertical.
854    let raw = [b[0] - a[0], b[1] - a[1]];
855    let flip = if alignment.eq_ignore_ascii_case(HORIZONTAL) {
856        raw[0] < 0.0
857    } else if alignment.eq_ignore_ascii_case(VERTICAL) {
858        raw[1] > 0.0
859    } else {
860        raw[0] < 0.0 || (raw[0] == 0.0 && raw[1] > 0.0)
861    };
862    let (a, b) = if flip { (b, a) } else { (a, b) };
863    let span = [b[0] - a[0], b[1] - a[1]];
864    let d = match alignment {
865        _ if alignment.eq_ignore_ascii_case(HORIZONTAL) => [1.0, 0.0],
866        _ if alignment.eq_ignore_ascii_case(VERTICAL) => [0.0, -1.0],
867        _ => {
868            let len = length2(span);
869            if len < 1e-9 {
870                return DimensionDrawing::refused(
871                    dimension,
872                    first.at,
873                    "the two anchors project to the same point".into(),
874                );
875            }
876            [span[0] / len, span[1] / len]
877        }
878    };
879    // The dimension line's own normal, the side the offset moves it to.
880    let n = [-d[1], d[0]];
881    let value_mm = (span[0] * d[0] + span[1] * d[1]).abs();
882    if value_mm < 1e-9 {
883        return DimensionDrawing::refused(
884            dimension,
885            first.at,
886            format!("the two anchors have no {} separation", dimension.alignment),
887        );
888    }
889    // The kernel's own X / Y / Z component rule, read in the sheet's axes: the
890    // layout draws the dimension line along `b - a`, so a component dimension
891    // measures a PROJECTED pair. The common cross-coordinate is the anchor
892    // FARTHER from the side the offset moves to, so the dimension line clears
893    // both anchors rather than landing between them.
894    let (ca, cb) = (a[0] * n[0] + a[1] * n[1], b[0] * n[0] + b[1] * n[1]);
895    let base = if dimension.offset_mm >= 0.0 { ca.min(cb) } else { ca.max(cb) };
896    let onto = |p: [f64; 3], c: f64| -> [f64; 3] {
897        let along = p[0] * d[0] + p[1] * d[1];
898        [along * d[0] + c * n[0], along * d[1] + c * n[1], 0.0]
899    };
900    let (pa, pb) = (onto(a, base), onto(b, base));
901    // Paper distance determines layout, but dividing it by scale cannot
902    // recover the depth lost to projection. Aligned dimensions measure the
903    // original 3D pair; horizontal/vertical dimensions measure view components.
904    let aligned = alignment.eq_ignore_ascii_case(ALIGNED);
905    let value = if aligned {
906        first.model.iter().zip(second.model).map(|(a, b)| (b - a).powi(2)).sum::<f64>().sqrt()
907    } else {
908        value_mm / view.scale
909    };
910    // A free PMI dimension between these very two points hands its block on —
911    // to an ALIGNED sheet dimension only: a horizontal or vertical one measures
912    // a component, which is a different number.
913    let text = value_text(dimension, view, value, ("", ""), |measures| match measures {
914        Measures::Linear { a, b } => {
915            aligned
916                && ((same_point(view, *a, first.at) && same_point(view, *b, second.at))
917                    || (same_point(view, *a, second.at) && same_point(view, *b, first.at)))
918        }
919        _ => false,
920    });
921    let style = style(view);
922    // Where the value sits along the line. Centred when its box and both
923    // arrowheads fit between the extension lines; otherwise just PAST the far
924    // one, one arrowhead clear of it — the layout runs the dimension line on
925    // to the value's station — because a value centred on a line too short for
926    // it would cover the line and break both arrowheads out of the drawing. A
927    // toleranced value is two or three times the plain number's width, which
928    // is when this starts to matter.
929    let half = super::project::text_half_width(&text.0, style.text_height);
930    let along = if 2.0 * (half + style.arrow) <= value_mm {
931        [(pa[0] + pb[0]) * 0.5, (pa[1] + pb[1]) * 0.5]
932    } else {
933        let past = style.arrow + half;
934        [pb[0] + d[0] * past, pb[1] + d[1] * past]
935    };
936    let label = [along[0] + n[0] * dimension.offset_mm, along[1] + n[1] * dimension.offset_mm, 0.0];
937    let drawn = pmi_present(
938        &PmiGeometry::Linear { a: pa, b: pb, component: None },
939        label,
940        &text.0,
941        &style,
942    );
943    // The layout's extension lines start at the pair it MEASURED. Re-seat each
944    // on its true anchor and run it to the layout's own overshoot past the
945    // dimension line — computed from the anchor's own side, so an anchor
946    // BEYOND the line still reaches past it rather than stopping short.
947    let overshoot = style.arrow * 0.6;
948    let reseat: Vec<(([f64; 3], [f64; 3]), [f64; 3])> = [(pa, a), (pb, b)]
949        .iter()
950        .filter_map(|(projected, truth)| {
951            let foot = [
952                projected[0] + n[0] * dimension.offset_mm,
953                projected[1] + n[1] * dimension.offset_mm,
954                0.0,
955            ];
956            let away = [foot[0] - truth[0], foot[1] - truth[1]];
957            let len = length2(away);
958            if len < 1e-9 {
959                return None;
960            }
961            let end = [
962                foot[0] + away[0] / len * overshoot,
963                foot[1] + away[1] / len * overshoot,
964                0.0,
965            ];
966            Some(((*projected, end), *truth))
967        })
968        .collect();
969    finish(dimension, drawn, value, text, &reseat)
970}
971
972// ---------------------------------------------------------------------------
973// Radial and diametral
974// ---------------------------------------------------------------------------
975
976fn radial(dimension: &SheetDimension, view: &ViewDrawing, anchor: &AnchorPoint) -> DimensionDrawing {
977    let (Some(radius), Some(u), Some(v)) = (anchor.radius, anchor.u, anchor.v) else {
978        return DimensionDrawing::refused(
979            dimension,
980            anchor.at,
981            format!("'{}' is not a circular edge", anchor.anchor),
982        );
983    };
984    let diameter = dimension.kind.eq_ignore_ascii_case(DIAMETRAL);
985    // The projected circle is an ELLIPSE — `u` and `v` are the paper images of
986    // its two conjugate radii — so "the radius" the leader reaches is the
987    // ellipse's extent along the leader's own direction. An ellipse is centrally
988    // symmetric, so the diameter line through the centre reaches the same
989    // distance on both sides, which is exactly what the shared layout draws.
990    let dir = RADIAL_DIR;
991    let (u, v) = ([u[0], -u[1]], [v[0], -v[1]]);
992    let theta = (-cross2(u, dir)).atan2(cross2(v, dir));
993    let at = |t: f64| [u[0] * t.cos() + v[0] * t.sin(), u[1] * t.cos() + v[1] * t.sin()];
994    let mut point = at(theta);
995    if point[0] * dir[0] + point[1] * dir[1] < 0.0 {
996        point = at(theta + std::f64::consts::PI);
997    }
998    let paper_radius = length2(point);
999    if !(paper_radius > 1e-9) {
1000        return DimensionDrawing::refused(
1001            dimension,
1002            anchor.at,
1003            format!("'{}' projects edge-on: its circle has no radius on the paper", anchor.anchor),
1004        );
1005    }
1006    let centre = lift(anchor.at);
1007    let reach = paper_radius + dimension.offset_mm;
1008    let label = [centre[0] + dir[0] * reach, centre[1] + dir[1] * reach, 0.0];
1009    let value = if diameter { radius * 2.0 } else { radius };
1010    // A PMI radial (or diametral) dimension of this circle — the same centre on
1011    // the paper and the same MODEL radius to the B-rep bar — hands its block on
1012    // to the sheet dimension of the same kind.
1013    let bar = super::hlr::Floors::of(view.scale).bar;
1014    let text = value_text(
1015        dimension,
1016        view,
1017        value,
1018        (if diameter { "\u{2300}" } else { "R" }, ""),
1019        |measures| match measures {
1020            Measures::Radial { centre, radius: measured, diameter: pmi_diameter } => {
1021                *pmi_diameter == diameter && same_point(view, *centre, anchor.at) && (measured - radius).abs() <= bar
1022            }
1023            _ => false,
1024        },
1025    );
1026    let style = style(view);
1027    let drawn = pmi_present(
1028        &PmiGeometry::Radial {
1029            center: centre,
1030            axis: [0.0, 0.0, 1.0],
1031            radius: paper_radius,
1032            diameter,
1033            sphere: false,
1034        },
1035        label,
1036        &text.0,
1037        &style,
1038    );
1039    finish(dimension, drawn, value, text, &[])
1040}
1041
1042
1043// ---------------------------------------------------------------------------
1044// Angular
1045// ---------------------------------------------------------------------------
1046
1047/// The angle between two projected STRAIGHT edges, as an arc with tangent
1048/// arrowheads and the value in degrees.
1049///
1050/// Both anchors are `edge` candidates, and each carries its edge's own unit
1051/// PAPER direction ([`AnchorPoint::dir`]) — the direction as THIS view projects
1052/// it, so a foreshortened angle reads its projected value rather than the
1053/// model's. That is the drawing convention and it is what an orthographic view
1054/// can honestly say: the true angle between two skew or tilted edges is not a
1055/// property of this projection.
1056///
1057/// **Which angle.** Two lines crossing make four angles, and the pair of RAYS
1058/// this measures is chosen by the anchors themselves: each ray leaves the
1059/// vertex toward the anchor POINT that was picked, so picking an edge's near
1060/// end and its far end pick supplementary angles. The value is therefore always
1061/// the angle between the two picked rays, strictly between 0° and 180°.
1062///
1063/// `offsetMm` is the arc's radius from the vertex, measured along the bisector
1064/// of the two rays — the one number a single drag can move, exactly as a radial
1065/// dimension's offset is.
1066fn angular(
1067    dimension: &SheetDimension,
1068    view: &ViewDrawing,
1069    first: &AnchorPoint,
1070    second: &AnchorPoint,
1071) -> DimensionDrawing {
1072    let direction = |anchor: &AnchorPoint| -> Result<[f64; 2], String> {
1073        anchor.dir.ok_or_else(|| {
1074            if anchor.kind == EDGE {
1075                format!(
1076                    "'{}' is not a straight edge in this view; an angular dimension measures between two straight edges",
1077                    anchor.anchor
1078                )
1079            } else {
1080                format!("'{}' is a {} anchor; an angular dimension measures between two EDGES", anchor.anchor, anchor.kind)
1081            }
1082        })
1083    };
1084    let (da, db) = match (direction(first), direction(second)) {
1085        (Ok(a), Ok(b)) => (a, b),
1086        (Err(error), _) | (_, Err(error)) => {
1087            return DimensionDrawing::refused(dimension, first.at, error)
1088        }
1089    };
1090    // Where the two lines cross — the arc's centre and the vertex the
1091    // extension lines run out from. Parallel lines never do, and there is no
1092    // angle to draw rather than a very distant one.
1093    let denominator = cross2(da, db);
1094    if denominator.abs() < 1e-9 {
1095        return DimensionDrawing::refused(
1096            dimension,
1097            first.at,
1098            format!(
1099                "'{}' and '{}' are parallel in this view; they make no angle",
1100                first.anchor, second.anchor
1101            ),
1102        );
1103    }
1104    let between = [second.at[0] - first.at[0], second.at[1] - first.at[1]];
1105    let t = cross2(between, db) / denominator;
1106    let vertex = [first.at[0] + da[0] * t, first.at[1] + da[1] * t];
1107    // Each ray points from the vertex at the anchor that was picked. An anchor
1108    // sitting ON the vertex — an edge picked at the very end the other one
1109    // meets — has no side to name, so the edge's own direction stands in.
1110    let ray = |anchor: &AnchorPoint, fallback: [f64; 2]| -> [f64; 2] {
1111        let away = [anchor.at[0] - vertex[0], anchor.at[1] - vertex[1]];
1112        let length = length2(away);
1113        if length < 1e-9 {
1114            fallback
1115        } else {
1116            [away[0] / length, away[1] / length]
1117        }
1118    };
1119    let (ra, rb) = (ray(first, da), ray(second, db));
1120    let degrees = cross2(ra, rb).abs().atan2(ra[0] * rb[0] + ra[1] * rb[1]).to_degrees();
1121    if !(degrees > 1e-6) {
1122        return DimensionDrawing::refused(
1123            dimension,
1124            first.at,
1125            format!(
1126                "'{}' and '{}' leave the vertex the same way; they make no angle",
1127                first.anchor, second.anchor
1128            ),
1129        );
1130    }
1131    // Into the layout's own frame (x right, y UP, z out of the page), where the
1132    // arc sweeps `degrees` from `dir_a` about +z RIGHT-HANDED — so the pair is
1133    // ordered by that sweep rather than by the order the anchors were picked.
1134    let (la, lb) = (lift_dir(ra), lift_dir(rb));
1135    let (dir_a, dir_b) = if cross2([la[0], la[1]], [lb[0], lb[1]]) >= 0.0 {
1136        (la, lb)
1137    } else {
1138        (lb, la)
1139    };
1140    let bisector = [dir_a[0] + dir_b[0], dir_a[1] + dir_b[1]];
1141    let length = length2([bisector[0], bisector[1]]);
1142    // `degrees` is strictly below 180, so the two rays never cancel.
1143    let bisector = [bisector[0] / length, bisector[1] / length, 0.0];
1144    let centre = lift(vertex);
1145    let label = [
1146        centre[0] + bisector[0] * dimension.offset_mm,
1147        centre[1] + bisector[1] * dimension.offset_mm,
1148        0.0,
1149    ];
1150    // The kernel's own angle string: the number at this sheet's precision with
1151    // a degree sign, the same text a PMI angle annotation carries.
1152    // An angle inherits nothing: the sheet reads the angle AS THIS VIEW
1153    // PROJECTS IT, which is the model's angle only when the view looks down
1154    // the angle's axis, and a tolerance on one is not a tolerance on the other.
1155    let text = value_text(dimension, view, degrees, ("", "\u{00B0}"), |_| false);
1156    let style = style(view);
1157    let drawn = pmi_present(
1158        &PmiGeometry::Angular {
1159            vertex: centre,
1160            dir_a,
1161            dir_b,
1162            axis: [0.0, 0.0, 1.0],
1163            degrees,
1164        },
1165        label,
1166        &text.0,
1167        &style,
1168    );
1169    finish(dimension, drawn, degrees, text, &[])
1170}
1171
1172