Skip to main content

brep_render/sheets/
ordinate.rs

1//! Ordinate sets — a DATUM and a run of members, each reading its signed
2//! distance from the datum along one axis of the paper.
3//!
4//! # Why this is not a fifth dimension kind
5//!
6//! A [`super::dimension::SheetDimension`] measures ONE thing and draws one
7//! dimension line for it. An ordinate set measures any number of points
8//! against a shared origin and draws one leader per point to a shared
9//! baseline, and there is no dimension line at all. Its anchors are not a
10//! fixed-length list, its value is not one number, and deleting its datum
11//! deletes every value it defines. That is a different object, so it is one:
12//! `Sheet::ordinates` beside `Sheet::dimensions`, with its own id prefix
13//! (`OD{n}` from the one counter) and its own form.
14//!
15//! # The drawing
16//!
17//! The `axis` picks which paper component is measured and therefore which way
18//! the leaders run:
19//!
20//! | axis | value | leader | baseline |
21//! |---|---|---|---|
22//! | `horizontal` | paper x from the datum, right positive | vertical | a paper y |
23//! | `vertical` | paper y from the datum, **UP** positive | horizontal | a paper x |
24//!
25//! A vertical set negates the paper component on purpose: paper y runs DOWN
26//! (SVG's frame) and a drawing's vertical ordinates count UP, so the sign the
27//! reader sees is the model's and not the file format's.
28//!
29//! `offsetMm` is the baseline's signed distance from the DATUM along the
30//! leader direction — one number, so the whole set moves with one drag, and
31//! its sign picks which side of the datum the values are written on.
32//!
33//! Every station draws a leader from its anchor to the baseline and its value
34//! just past the end, flat on the sheet. The DATUM draws too, reading `0.000`
35//! and carrying the small circle that is the ordinate origin's own symbol —
36//! without it a reader cannot see which of the values the others are measured
37//! from.
38//!
39//! # What a lost anchor does
40//!
41//! A lost DATUM takes the set: there is nothing left to measure from, so the
42//! row carries the reason and draws nothing ([`OrdinateDrawing::error`]). A
43//! lost MEMBER takes only itself — its own station carries the reason
44//! ([`OrdinateMember::error`]) and the rest of the set still draws, because a
45//! set that vanished when one of five points moved would be worse than one
46//! that says which point went. That is the same rule a dimension follows,
47//! read at the right granularity.
48
49use serde::{Deserialize, Serialize};
50use serde_json::{json, Value};
51
52use super::dimension::{SheetAnchor, HORIZONTAL, VERTICAL};
53use super::project::{SheetText, ViewDrawing};
54use brep_kernel::{format_dimension, format_number};
55
56/// The axes a set can be measured along — a linear dimension's own two, minus
57/// `aligned`: an ordinate run has no arbitrary direction, because the whole
58/// point is that every value shares one.
59pub const AXES: &[&str] = &[HORIZONTAL, VERTICAL];
60
61/// A new set's baseline offset from its datum, paper millimetres.
62pub const DEFAULT_OFFSET_MM: f64 = 16.0;
63
64/// Decimals in an ordinate value, the linear default.
65pub const DEFAULT_PRECISION: u32 = 3;
66
67/// The clear space between a leader's end and its value, as a multiple of the
68/// cap height.
69const VALUE_GAP: f64 = 0.5;
70
71/// The ordinate ORIGIN symbol's radius at the datum, as a multiple of the cap
72/// height.
73const ORIGIN_RADIUS: f64 = 0.35;
74
75/// Segments the origin circle is drawn with — the sheet's contract is lines,
76/// so a circle is a closed polyline exactly as an arrowhead is.
77const ORIGIN_STEPS: usize = 24;
78
79/// How many rows a crowded value may be staggered away from the baseline
80/// before the run gives up and lets it print where it falls. A set dense
81/// enough to need more than this is telling the reader to raise the scale.
82const MAX_STAGGER_ROWS: usize = 8;
83
84/// The clear space two values have to leave each other, as a multiple of the
85/// cap height: ONE character advance — two numbers with less than a space
86/// between them read as one longer number, which is the failure this is here
87/// to stop, not merely ink overlapping ink.
88const VALUE_PAD: f64 = super::project::TEXT_ADVANCE;
89
90// ===========================================================================
91// The block
92// ===========================================================================
93
94/// One ordinate set on a sheet.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct SheetOrdinate {
97    pub id: String,
98    /// [`HORIZONTAL`] or [`VERTICAL`].
99    #[serde(default = "default_axis")]
100    pub axis: String,
101    /// The anchor reference every member is measured FROM. Empty is a set
102    /// with no datum — what the form leaves behind when its datum row is
103    /// cleared, and the engine deletes the set rather than keeping it.
104    #[serde(default)]
105    pub datum: String,
106    /// The measured anchors, in the order they read.
107    #[serde(default)]
108    pub members: Vec<String>,
109    /// The baseline's signed distance from the datum, paper millimetres.
110    #[serde(default = "default_offset", rename = "offsetMm")]
111    pub offset_mm: f64,
112    #[serde(default = "default_precision")]
113    pub precision: u32,
114    /// The tolerance block every MEMBER's value is written with. The datum
115    /// reads a plain `0.000`: it is the origin the others are measured from,
116    /// exact by definition.
117    #[serde(flatten)]
118    pub tolerance: super::dimension::SheetTolerance,
119}
120
121fn default_axis() -> String {
122    HORIZONTAL.to_string()
123}
124
125fn default_offset() -> f64 {
126    DEFAULT_OFFSET_MM
127}
128
129fn default_precision() -> u32 {
130    DEFAULT_PRECISION
131}
132
133impl SheetOrdinate {
134    pub fn new(id: String, axis: &str, datum: String) -> SheetOrdinate {
135        SheetOrdinate {
136            id,
137            axis: axis.to_string(),
138            datum,
139            members: Vec::new(),
140            offset_mm: DEFAULT_OFFSET_MM,
141            precision: DEFAULT_PRECISION,
142            tolerance: Default::default(),
143        }
144    }
145
146    /// The params the **Ordinate set** form edits.
147    pub fn params(&self) -> Value {
148        let mut params = json!({
149            "id": self.id,
150            "axis": self.axis,
151            "datum": if self.datum.is_empty() { Value::Null } else { Value::String(self.datum.clone()) },
152            "members": self.members,
153            "offsetMm": self.offset_mm,
154            "precision": self.precision,
155        });
156        self.tolerance.write_params(&mut params);
157        params
158    }
159
160    /// Apply an edited **Ordinate set** form. `id` is identity and is never
161    /// taken from the form.
162    pub fn apply(&mut self, params: &Value) -> Result<(), String> {
163        if let Some(axis) = params.get("axis").and_then(Value::as_str) {
164            let axis = axis.trim();
165            let Some(known) = AXES.iter().find(|a| a.eq_ignore_ascii_case(axis)) else {
166                return Err(format!("no ordinate axis '{axis}'"));
167            };
168            self.axis = (*known).to_string();
169        }
170        // The DATUM is a single-reference field, so the form hands back either
171        // a string or a one-element list; both mean the same thing and an
172        // empty one means the row was cleared.
173        if let Some(datum) = params.get("datum") {
174            let picked: Vec<String> = match datum.as_str() {
175                Some(one) => vec![one.to_string()],
176                None => crate::json_support::string_values(Some(datum)).map(str::to_string).collect(),
177            }
178            .into_iter()
179            .filter(|anchor| !anchor.is_empty())
180            .collect();
181            if let Some(first) = picked.first() {
182                SheetAnchor::parse(first)?;
183            }
184            self.datum = picked.into_iter().next().unwrap_or_default();
185        }
186        if let Some(members) = params.get("members") {
187            let list: Vec<String> = crate::json_support::string_values(Some(members))
188                .map(str::to_string)
189                .collect();
190            for anchor in &list {
191                SheetAnchor::parse(anchor)?;
192            }
193            self.members = list;
194        }
195        super::dimension::apply_offset_precision(params, &mut self.offset_mm, &mut self.precision)?;
196        self.tolerance.apply(params)
197    }
198}
199
200/// The **Ordinate set** dialog's schema, in the `inputParamsSchema` vocabulary
201/// the shared form engine renders.
202///
203/// TWO reference fields rather than one list with a magic first row: the datum
204/// and the members answer different questions, and `datum`'s ✕ has to mean
205/// "this set has no origin any more" while a member's ✕ means "drop that one
206/// value". A single list could not tell those apart without counting rows.
207pub fn sheet_ordinate_schema() -> Value {
208    let mut fields = json!({
209        "id": { "type": "string", "default_value": null },
210        "axis": { "type": "options", "label": "Axis", "options": AXES, "default_value": HORIZONTAL },
211        "datum": { "type": "reference_selection", "selectionFilter": ["sheetAnchor"], "multiple": false },
212        "members": { "type": "reference_selection", "selectionFilter": ["sheetAnchor"], "multiple": true },
213        "offsetMm": { "type": "number", "label": "Baseline offset (mm)", "default_value": DEFAULT_OFFSET_MM },
214        "precision": { "type": "number", "label": "Precision (decimals)", "default_value": DEFAULT_PRECISION },
215    });
216    // The tolerance block, the PMI dimension form's own four fields.
217    super::dimension::SheetTolerance::schema_fields(fields.as_object_mut().expect("an object"));
218    json!({
219        "type": "sheetOrdinate",
220        "shortName": "OD",
221        "longName": "Ordinate set",
222        "inputParamsSchema": fields,
223    })
224}
225
226// ===========================================================================
227// The drawing
228// ===========================================================================
229
230/// One ordinate set, laid out in paper millimetres.
231#[derive(Debug, Clone, PartialEq, serde::Serialize)]
232pub struct OrdinateDrawing {
233    pub id: String,
234    pub axis: String,
235    /// Every station's leader, and the datum's origin circle.
236    pub lines: Vec<Vec<[f64; 2]>>,
237    pub texts: Vec<SheetText>,
238    /// The datum's own value position — the point the baseline drag grabs, and
239    /// where a set with no datum left writes its reason.
240    pub label: [f64; 2],
241    #[serde(rename = "offsetMm")]
242    pub offset_mm: f64,
243    /// The datum and every member, in reading order.
244    pub stations: Vec<OrdinateMember>,
245    /// Empty when the set resolved; why the whole set did not otherwise — a
246    /// lost DATUM, a member on another placement, a placement that did not
247    /// project. A lost MEMBER is on its own station instead.
248    #[serde(skip_serializing_if = "String::is_empty")]
249    pub error: String,
250}
251
252/// One station of a set — the datum, or one member.
253#[derive(Debug, Clone, PartialEq, serde::Serialize)]
254pub struct OrdinateMember {
255    #[serde(rename = "ref")]
256    pub anchor: String,
257    /// Whether this station IS the datum (value 0, and the origin symbol).
258    pub datum: bool,
259    /// The signed model distance from the datum along the set's axis (`NaN`
260    /// when this station did not resolve).
261    pub value: f64,
262    /// The formatted value, empty when it did not resolve.
263    pub text: String,
264    /// The anchor's paper point.
265    pub at: [f64; 2],
266    /// Empty when this station resolved; why it did not otherwise.
267    #[serde(skip_serializing_if = "String::is_empty")]
268    pub error: String,
269}
270
271impl OrdinateDrawing {
272    fn refused(set: &SheetOrdinate, label: [f64; 2], error: String) -> OrdinateDrawing {
273        OrdinateDrawing {
274            id: set.id.clone(),
275            axis: set.axis.clone(),
276            lines: Vec::new(),
277            texts: Vec::new(),
278            label,
279            offset_mm: set.offset_mm,
280            stations: Vec::new(),
281            error,
282        }
283    }
284
285    /// How many stations carry their own reason — what the pane's row and the
286    /// viewport's note count.
287    pub fn lost(&self) -> usize {
288        self.stations.iter().filter(|s| !s.error.is_empty()).count()
289    }
290}
291
292/// Lay out every ordinate set of a sheet against the placements already
293/// projected into `views`. `centre` is where a set with no datum left writes
294/// its reason.
295pub fn layout_ordinates(
296    ordinates: &[SheetOrdinate],
297    views: &[ViewDrawing],
298    centre: [f64; 2],
299) -> Vec<OrdinateDrawing> {
300    ordinates
301        .iter()
302        .map(|set| layout_one(set, views, centre))
303        .collect()
304}
305
306fn layout_one(
307    set: &SheetOrdinate,
308    views: &[ViewDrawing],
309    centre: [f64; 2],
310) -> OrdinateDrawing {
311    if set.datum.trim().is_empty() {
312        return OrdinateDrawing::refused(
313            set,
314            centre,
315            "an ordinate set has no datum; every value is measured from one".into(),
316        );
317    }
318    let (view, datum) = match super::dimension::resolve_anchor(&set.datum, views) {
319        Ok(found) => found,
320        Err(error) => return OrdinateDrawing::refused(set, centre, error),
321    };
322    if view.scale <= 0.0 {
323        return OrdinateDrawing::refused(
324            set,
325            datum.at,
326            format!("placement '{}' has no scale", view.id),
327        );
328    }
329    let vertical = set.axis.eq_ignore_ascii_case(VERTICAL);
330    // The leader direction — which way a station reaches its baseline — and
331    // the level the baseline sits at, both from the ONE offset.
332    let sign = if set.offset_mm < 0.0 { -1.0 } else { 1.0 };
333    let level = if vertical { datum.at[0] } else { datum.at[1] } + set.offset_mm;
334    let cap = super::dimension::text_height(view);
335
336    let mut lines: Vec<Vec<[f64; 2]>> = Vec::new();
337    let mut texts: Vec<SheetText> = Vec::new();
338    let mut stations: Vec<OrdinateMember> = Vec::new();
339    // Every value box already laid, so the next one can be jogged clear of it.
340    let mut placed: Vec<[f64; 4]> = Vec::new();
341    let mut label = datum.at;
342
343    let references = std::iter::once((&set.datum, true))
344        .chain(set.members.iter().map(|member| (member, false)));
345    for (reference, is_datum) in references {
346        let point = if is_datum {
347            Ok(datum)
348        } else {
349            match super::dimension::resolve_anchor(reference, views) {
350                // A member on ANOTHER placement measures against a different
351                // projection, which is the linear dimension's refusal read at
352                // the station rather than at the set.
353                Ok((other, _)) if other.id != view.id => Err(format!(
354                    "'{reference}' is on placement '{}', not on the datum's '{}'",
355                    other.id, view.id
356                )),
357                Ok((_, found)) => Ok(found),
358                Err(error) => Err(error),
359            }
360        };
361        let found = match point {
362            Ok(found) => found,
363            Err(error) => {
364                stations.push(OrdinateMember {
365                    anchor: reference.clone(),
366                    datum: is_datum,
367                    value: f64::NAN,
368                    text: String::new(),
369                    at: [f64::NAN, f64::NAN],
370                    error,
371                });
372                continue;
373            }
374        };
375        // The signed MODEL distance along the axis. A vertical set counts UP,
376        // so the paper's downward y is negated before it is scaled.
377        let value = if vertical {
378            (datum.at[1] - found.at[1]) / view.scale
379        } else {
380            (found.at[0] - datum.at[0]) / view.scale
381        };
382        // Every member through the kernel's own dimension text, with the set's
383        // block; the datum is the origin and reads a plain zero.
384        let text = if is_datum {
385            format_number(value, set.precision as usize)
386        } else {
387            let block = set.tolerance.block();
388            format_dimension(value, set.precision as usize, &block, set.tolerance.reference, "", "")
389        };
390        // The leader, from the anchor to the baseline.
391        let foot = if vertical {
392            [level, found.at[1]]
393        } else {
394            [found.at[0], level]
395        };
396        // …and the value just past it, clear of the leader's end. A flat run
397        // grows along x, so a VERTICAL set has to clear half a text WIDTH
398        // where a horizontal one clears half a cap height.
399        let half_width = 0.5 * text.chars().count() as f64 * super::project::TEXT_ADVANCE * cap;
400        let clear = cap * VALUE_GAP + if vertical { half_width } else { 0.5 * cap };
401        let away = |distance: f64| {
402            if vertical {
403                [foot[0] + sign * distance, foot[1]]
404            } else {
405                [foot[0], foot[1] + sign * distance]
406            }
407        };
408        // Two stations closer together than their values are wide would print
409        // one number through the other, which is the one way a run of numbers
410        // stops being readable — and a pair of stations over ONE paper
411        // position (two points differing only ACROSS the measured axis) is not
412        // an edge case, it is what an ordinate set is for. A value that lands
413        // on one already placed is STAGGERED a row further from the baseline,
414        // which is what a drafter does with a crowded run. The leaders do not
415        // move: every one of them still ends on the common baseline, and a
416        // staggered number reads under the one above it.
417        //
418        // A row is the value's own extent plus TWICE the pad, so one step
419        // always clears — a row of exactly the pad lands the two boxes edge to
420        // edge, where the collision test's own arithmetic decides it, and the
421        // value is stepped a second time for nothing.
422        let row = 2.0 * cap * VALUE_PAD + if vertical { 2.0 * half_width } else { cap };
423        let mut anchor = away(clear);
424        for step in 1..=MAX_STAGGER_ROWS {
425            if !placed
426                .iter()
427                .any(|other| boxes_collide(other, &value_box(anchor, half_width, cap), cap))
428            {
429                break;
430            }
431            anchor = away(clear + step as f64 * row);
432        }
433        placed.push(value_box(anchor, half_width, cap));
434        lines.push(vec![found.at, foot]);
435        texts.push(SheetText::flat(text.clone(), anchor, cap));
436        if is_datum {
437            lines.push(origin_circle(found.at, cap * ORIGIN_RADIUS));
438            label = anchor;
439        }
440        stations.push(OrdinateMember {
441            anchor: reference.clone(),
442            datum: is_datum,
443            value,
444            text,
445            at: found.at,
446            error: String::new(),
447        });
448    }
449    // The sheet's own text pass, exactly as a dimension's: a leader that ran
450    // into its own value is broken around it rather than struck through it.
451    let lines = super::project::break_for_text_boxes(lines, &texts);
452    OrdinateDrawing {
453        id: set.id.clone(),
454        axis: set.axis.clone(),
455        lines,
456        texts,
457        label,
458        offset_mm: set.offset_mm,
459        stations,
460        error: String::new(),
461    }
462}
463
464/// A value's footprint on the paper as `[x0, x1, y0, y1]`, centred on the
465/// anchor the text is drawn from: the text is centred and vertically centred,
466/// so the box is symmetric about it in both directions.
467fn value_box(anchor: [f64; 2], half_width: f64, cap: f64) -> [f64; 4] {
468    [
469        anchor[0] - half_width,
470        anchor[0] + half_width,
471        anchor[1] - 0.5 * cap,
472        anchor[1] + 0.5 * cap,
473    ]
474}
475
476/// Whether two value boxes are closer than `VALUE_PAD` of a cap height — the
477/// test the stagger is made on. Nearly touching counts: two numbers printed a
478/// hair apart read as one longer number.
479fn boxes_collide(a: &[f64; 4], b: &[f64; 4], cap: f64) -> bool {
480    let pad = cap * VALUE_PAD;
481    a[0] < b[1] + pad && b[0] < a[1] + pad && a[2] < b[3] + pad && b[2] < a[3] + pad
482}
483
484/// The ordinate ORIGIN symbol: the small circle at the datum, as a closed
485/// polyline — the sheet draws lines, so a circle is a polygon here exactly as
486/// an arrowhead is a closed outline.
487fn origin_circle(centre: [f64; 2], radius: f64) -> Vec<[f64; 2]> {
488    (0..=ORIGIN_STEPS)
489        .map(|step| {
490            let angle = std::f64::consts::TAU * step as f64 / ORIGIN_STEPS as f64;
491            [centre[0] + radius * angle.cos(), centre[1] + radius * angle.sin()]
492        })
493        .collect()
494}
495