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        if let Some(offset) = params.get("offsetMm").and_then(Value::as_f64) {
196            if !offset.is_finite() {
197                return Err(format!("offsetMm: {offset} is not a length"));
198            }
199            self.offset_mm = offset;
200        }
201        if let Some(precision) = params.get("precision").and_then(Value::as_f64) {
202            if !precision.is_finite() || !(0.0..=8.0).contains(&precision) {
203                return Err(format!("precision: {precision} is not 0 to 8 decimals"));
204            }
205            self.precision = precision.round() as u32;
206        }
207        self.tolerance.apply(params)
208    }
209}
210
211/// The **Ordinate set** dialog's schema, in the `inputParamsSchema` vocabulary
212/// the shared form engine renders.
213///
214/// TWO reference fields rather than one list with a magic first row: the datum
215/// and the members answer different questions, and `datum`'s ✕ has to mean
216/// "this set has no origin any more" while a member's ✕ means "drop that one
217/// value". A single list could not tell those apart without counting rows.
218pub fn sheet_ordinate_schema() -> Value {
219    let mut fields = json!({
220        "id": { "type": "string", "default_value": null },
221        "axis": { "type": "options", "label": "Axis", "options": AXES, "default_value": HORIZONTAL },
222        "datum": { "type": "reference_selection", "selectionFilter": ["sheetAnchor"], "multiple": false },
223        "members": { "type": "reference_selection", "selectionFilter": ["sheetAnchor"], "multiple": true },
224        "offsetMm": { "type": "number", "label": "Baseline offset (mm)", "default_value": DEFAULT_OFFSET_MM },
225        "precision": { "type": "number", "label": "Precision (decimals)", "default_value": DEFAULT_PRECISION },
226    });
227    // The tolerance block, the PMI dimension form's own four fields.
228    super::dimension::SheetTolerance::schema_fields(fields.as_object_mut().expect("an object"));
229    json!({
230        "type": "sheetOrdinate",
231        "shortName": "OD",
232        "longName": "Ordinate set",
233        "inputParamsSchema": fields,
234    })
235}
236
237// ===========================================================================
238// The drawing
239// ===========================================================================
240
241/// One ordinate set, laid out in paper millimetres.
242#[derive(Debug, Clone, PartialEq, serde::Serialize)]
243pub struct OrdinateDrawing {
244    pub id: String,
245    pub axis: String,
246    /// Every station's leader, and the datum's origin circle.
247    pub lines: Vec<Vec<[f64; 2]>>,
248    pub texts: Vec<SheetText>,
249    /// The datum's own value position — the point the baseline drag grabs, and
250    /// where a set with no datum left writes its reason.
251    pub label: [f64; 2],
252    #[serde(rename = "offsetMm")]
253    pub offset_mm: f64,
254    /// The datum and every member, in reading order.
255    pub stations: Vec<OrdinateMember>,
256    /// Empty when the set resolved; why the whole set did not otherwise — a
257    /// lost DATUM, a member on another placement, a placement that did not
258    /// project. A lost MEMBER is on its own station instead.
259    #[serde(skip_serializing_if = "String::is_empty")]
260    pub error: String,
261}
262
263/// One station of a set — the datum, or one member.
264#[derive(Debug, Clone, PartialEq, serde::Serialize)]
265pub struct OrdinateMember {
266    #[serde(rename = "ref")]
267    pub anchor: String,
268    /// Whether this station IS the datum (value 0, and the origin symbol).
269    pub datum: bool,
270    /// The signed model distance from the datum along the set's axis (`NaN`
271    /// when this station did not resolve).
272    pub value: f64,
273    /// The formatted value, empty when it did not resolve.
274    pub text: String,
275    /// The anchor's paper point.
276    pub at: [f64; 2],
277    /// Empty when this station resolved; why it did not otherwise.
278    #[serde(skip_serializing_if = "String::is_empty")]
279    pub error: String,
280}
281
282impl OrdinateDrawing {
283    fn refused(set: &SheetOrdinate, label: [f64; 2], error: String) -> OrdinateDrawing {
284        OrdinateDrawing {
285            id: set.id.clone(),
286            axis: set.axis.clone(),
287            lines: Vec::new(),
288            texts: Vec::new(),
289            label,
290            offset_mm: set.offset_mm,
291            stations: Vec::new(),
292            error,
293        }
294    }
295
296    /// How many stations carry their own reason — what the pane's row and the
297    /// viewport's note count.
298    pub fn lost(&self) -> usize {
299        self.stations.iter().filter(|s| !s.error.is_empty()).count()
300    }
301}
302
303/// Lay out every ordinate set of a sheet against the placements already
304/// projected into `views`. `centre` is where a set with no datum left writes
305/// its reason.
306pub fn layout_ordinates(
307    ordinates: &[SheetOrdinate],
308    views: &[ViewDrawing],
309    centre: [f64; 2],
310) -> Vec<OrdinateDrawing> {
311    ordinates
312        .iter()
313        .map(|set| layout_one(set, views, centre))
314        .collect()
315}
316
317fn layout_one(
318    set: &SheetOrdinate,
319    views: &[ViewDrawing],
320    centre: [f64; 2],
321) -> OrdinateDrawing {
322    if set.datum.trim().is_empty() {
323        return OrdinateDrawing::refused(
324            set,
325            centre,
326            "an ordinate set has no datum; every value is measured from one".into(),
327        );
328    }
329    let (view, datum) = match super::dimension::resolve_anchor(&set.datum, views) {
330        Ok(found) => found,
331        Err(error) => return OrdinateDrawing::refused(set, centre, error),
332    };
333    if view.scale <= 0.0 {
334        return OrdinateDrawing::refused(
335            set,
336            datum.at,
337            format!("placement '{}' has no scale", view.id),
338        );
339    }
340    let vertical = set.axis.eq_ignore_ascii_case(VERTICAL);
341    // The leader direction — which way a station reaches its baseline — and
342    // the level the baseline sits at, both from the ONE offset.
343    let sign = if set.offset_mm < 0.0 { -1.0 } else { 1.0 };
344    let level = if vertical { datum.at[0] } else { datum.at[1] } + set.offset_mm;
345    let cap = super::dimension::text_height(view);
346
347    let mut lines: Vec<Vec<[f64; 2]>> = Vec::new();
348    let mut texts: Vec<SheetText> = Vec::new();
349    let mut stations: Vec<OrdinateMember> = Vec::new();
350    // Every value box already laid, so the next one can be jogged clear of it.
351    let mut placed: Vec<[f64; 4]> = Vec::new();
352    let mut label = datum.at;
353
354    let references = std::iter::once((&set.datum, true))
355        .chain(set.members.iter().map(|member| (member, false)));
356    for (reference, is_datum) in references {
357        let point = if is_datum {
358            Ok(datum)
359        } else {
360            match super::dimension::resolve_anchor(reference, views) {
361                // A member on ANOTHER placement measures against a different
362                // projection, which is the linear dimension's refusal read at
363                // the station rather than at the set.
364                Ok((other, _)) if other.id != view.id => Err(format!(
365                    "'{reference}' is on placement '{}', not on the datum's '{}'",
366                    other.id, view.id
367                )),
368                Ok((_, found)) => Ok(found),
369                Err(error) => Err(error),
370            }
371        };
372        let found = match point {
373            Ok(found) => found,
374            Err(error) => {
375                stations.push(OrdinateMember {
376                    anchor: reference.clone(),
377                    datum: is_datum,
378                    value: f64::NAN,
379                    text: String::new(),
380                    at: [f64::NAN, f64::NAN],
381                    error,
382                });
383                continue;
384            }
385        };
386        // The signed MODEL distance along the axis. A vertical set counts UP,
387        // so the paper's downward y is negated before it is scaled.
388        let value = if vertical {
389            (datum.at[1] - found.at[1]) / view.scale
390        } else {
391            (found.at[0] - datum.at[0]) / view.scale
392        };
393        // Every member through the kernel's own dimension text, with the set's
394        // block; the datum is the origin and reads a plain zero.
395        let text = if is_datum {
396            format_number(value, set.precision as usize)
397        } else {
398            let block = set.tolerance.block();
399            format_dimension(value, set.precision as usize, &block, set.tolerance.reference, "", "")
400        };
401        // The leader, from the anchor to the baseline.
402        let foot = if vertical {
403            [level, found.at[1]]
404        } else {
405            [found.at[0], level]
406        };
407        // …and the value just past it, clear of the leader's end. A flat run
408        // grows along x, so a VERTICAL set has to clear half a text WIDTH
409        // where a horizontal one clears half a cap height.
410        let half_width = 0.5 * text.chars().count() as f64 * super::project::TEXT_ADVANCE * cap;
411        let clear = cap * VALUE_GAP + if vertical { half_width } else { 0.5 * cap };
412        let away = |distance: f64| {
413            if vertical {
414                [foot[0] + sign * distance, foot[1]]
415            } else {
416                [foot[0], foot[1] + sign * distance]
417            }
418        };
419        // Two stations closer together than their values are wide would print
420        // one number through the other, which is the one way a run of numbers
421        // stops being readable — and a pair of stations over ONE paper
422        // position (two points differing only ACROSS the measured axis) is not
423        // an edge case, it is what an ordinate set is for. A value that lands
424        // on one already placed is STAGGERED a row further from the baseline,
425        // which is what a drafter does with a crowded run. The leaders do not
426        // move: every one of them still ends on the common baseline, and a
427        // staggered number reads under the one above it.
428        //
429        // A row is the value's own extent plus TWICE the pad, so one step
430        // always clears — a row of exactly the pad lands the two boxes edge to
431        // edge, where the collision test's own arithmetic decides it, and the
432        // value is stepped a second time for nothing.
433        let row = 2.0 * cap * VALUE_PAD + if vertical { 2.0 * half_width } else { cap };
434        let mut anchor = away(clear);
435        for step in 1..=MAX_STAGGER_ROWS {
436            if !placed
437                .iter()
438                .any(|other| boxes_collide(other, &value_box(anchor, half_width, cap), cap))
439            {
440                break;
441            }
442            anchor = away(clear + step as f64 * row);
443        }
444        placed.push(value_box(anchor, half_width, cap));
445        lines.push(vec![found.at, foot]);
446        texts.push(SheetText::flat(text.clone(), anchor, cap));
447        if is_datum {
448            lines.push(origin_circle(found.at, cap * ORIGIN_RADIUS));
449            label = anchor;
450        }
451        stations.push(OrdinateMember {
452            anchor: reference.clone(),
453            datum: is_datum,
454            value,
455            text,
456            at: found.at,
457            error: String::new(),
458        });
459    }
460    // The sheet's own text pass, exactly as a dimension's: a leader that ran
461    // into its own value is broken around it rather than struck through it.
462    let lines = super::project::break_for_text_boxes(lines, &texts);
463    OrdinateDrawing {
464        id: set.id.clone(),
465        axis: set.axis.clone(),
466        lines,
467        texts,
468        label,
469        offset_mm: set.offset_mm,
470        stations,
471        error: String::new(),
472    }
473}
474
475/// A value's footprint on the paper as `[x0, x1, y0, y1]`, centred on the
476/// anchor the text is drawn from: the text is centred and vertically centred,
477/// so the box is symmetric about it in both directions.
478fn value_box(anchor: [f64; 2], half_width: f64, cap: f64) -> [f64; 4] {
479    [
480        anchor[0] - half_width,
481        anchor[0] + half_width,
482        anchor[1] - 0.5 * cap,
483        anchor[1] + 0.5 * cap,
484    ]
485}
486
487/// Whether two value boxes are closer than `VALUE_PAD` of a cap height — the
488/// test the stagger is made on. Nearly touching counts: two numbers printed a
489/// hair apart read as one longer number.
490fn boxes_collide(a: &[f64; 4], b: &[f64; 4], cap: f64) -> bool {
491    let pad = cap * VALUE_PAD;
492    a[0] < b[1] + pad && b[0] < a[1] + pad && a[2] < b[3] + pad && b[2] < a[3] + pad
493}
494
495/// The ordinate ORIGIN symbol: the small circle at the datum, as a closed
496/// polyline — the sheet draws lines, so a circle is a polygon here exactly as
497/// an arrowhead is a closed outline.
498fn origin_circle(centre: [f64; 2], radius: f64) -> Vec<[f64; 2]> {
499    (0..=ORIGIN_STEPS)
500        .map(|step| {
501            let angle = std::f64::consts::TAU * step as f64 / ORIGIN_STEPS as f64;
502            [centre[0] + radius * angle.cos(), centre[1] + radius * angle.sin()]
503        })
504        .collect()
505}
506