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