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