Skip to main content

brep_render/sheets/
frame.rs

1//! The paper's own furniture: the drawing BORDER and the TITLE BLOCK.
2//!
3//! Both are the sheet's ([`crate::sheets::Sheet::border`],
4//! `border_inset_mm`, `title_block`, `notes`), both are on by default, and both
5//! come out of here as the same thing a placed view does — polylines and
6//! centred text runs in paper millimetres, measured from the paper's TOP-LEFT
7//! corner with y running down. That is what lets the SVG writer, the PDF writer
8//! and the sheet viewport draw the frame with the code they already have for
9//! the drawing: **lines and text only**, nothing filled.
10//!
11//! # The title block
12//!
13//! A fixed stamp in the bottom-right corner, inside the border: five cells, each
14//! a small LABEL over its VALUE.
15//!
16//! ```text
17//! +----------------------------------+
18//! | SHEET                            |
19//! | Page 1                           |
20//! +----------------------------------+
21//! | DOCUMENT                         |
22//! | bracket                          |
23//! +----------------+-----------------+
24//! | SCALE          | DATE            |
25//! | 4:1            | 2026-09-13      |
26//! +----------------+-----------------+
27//! | NOTES                            |
28//! | first article                    |
29//! +----------------------------------+
30//! ```
31//!
32//! Fixed, because a title block that scaled with the paper would be unreadable
33//! on an A0 and off the page on a small custom sheet; what DOES scale is the
34//! whole stamp on a sheet too small to carry it ([`SCALE_FLOOR`]), so a 60 × 40
35//! mm sheet gets a proportionally smaller one rather than a block hanging over
36//! its own border.
37//!
38//! `SCALE` is the FIRST placed view's — a sheet whose views are at different
39//! scales says so per view, and the stamp names the one the drawing is read at.
40//! `DATE` is the day the sheet was drawn or exported, UTC (see [`today_iso`]);
41//! it is part of the projection cache's key, so a session open across midnight
42//! re-projects once rather than printing yesterday.
43//!
44//! # The revision table
45//!
46//! The sheet's [`crate::sheets::Revision`] rows, as a table of three columns
47//! beside the title block — immediately to its LEFT, at the stamp's own scale,
48//! its bottom on the same margin — with a header row on top and the revisions
49//! under it in the order the list keeps them:
50//!
51//! ```text
52//! +-----+----------+----------------------------+----------------------------------+
53//! | REV |   DATE   |        DESCRIPTION         | SHEET                            |
54//! +-----+----------+----------------------------+ Page 1                           |
55//! |  A  |2026-09-01|       first release        +----------------------------------+
56//! +-----+----------+----------------------------+ …                                |
57//! |  B  |2026-09-14|       bore opened out      |                                  |
58//! +-----+----------+----------------------------+----------------------------------+
59//! ```
60//!
61//! Its width is the stamp's, 110 mm, so every preset carries the two side by
62//! side at full size. A sheet whose drawing area is too narrow for both puts
63//! the table ON TOP of the stamp instead, right-aligned with it; a sheet with
64//! the title block switched off puts it in the corner the stamp would have
65//! taken. One too short for the table in any of those places draws none, and
66//! an empty list never draws one. The text is the title block's: flat runs of
67//! the same cap heights, centred in their cells.
68
69use super::project::SheetText;
70use super::Sheet;
71
72/// The title block's size at full scale, millimetres.
73const TITLE_WIDTH_MM: f64 = 110.0;
74/// Its rows, top to bottom: sheet, document, scale|date, notes.
75const ROW_HEIGHTS_MM: [f64; 4] = [11.0, 8.0, 8.0, 8.0];
76/// Where the `SCALE | DATE` row divides, as a fraction of the width.
77const SPLIT: f64 = 0.5;
78/// The cap height of a cell's LABEL and of its VALUE, millimetres.
79const LABEL_HEIGHT_MM: f64 = 1.8;
80const VALUE_HEIGHT_MM: f64 = 3.2;
81/// The first row's value is the sheet's name, and reads as the title.
82const TITLE_HEIGHT_MM: f64 = 4.4;
83/// How much of a cell the label strip takes.
84const LABEL_BAND: f64 = 0.42;
85/// A cell's text is inset this far from its own edges, millimetres.
86const CELL_PAD_MM: f64 = 1.2;
87/// Below this scale factor the stamp is not drawn at all: a sheet that cannot
88/// carry a legible title block is better off with none.
89pub const SCALE_FLOOR: f64 = 0.35;
90/// The revision table's three columns at full scale, millimetres: REV, DATE
91/// and DESCRIPTION. They sum to [`TITLE_WIDTH_MM`], so the table stacks flush
92/// on the stamp when it has to go above it.
93const REVISION_COLUMNS_MM: [f64; 3] = [12.0, 24.0, 74.0];
94/// Its header row and each revision row, at full scale.
95const REVISION_HEADER_MM: f64 = 6.0;
96const REVISION_ROW_MM: f64 = 7.0;
97/// A revision cell's cap height — a size between the title block's label and
98/// its value, because a row is shorter than a stamp cell.
99const REVISION_TEXT_MM: f64 = 2.6;
100
101/// The whole title-block height at full scale.
102fn title_height_mm() -> f64 {
103    ROW_HEIGHTS_MM.iter().sum()
104}
105
106/// What the frame knows that the sheet does not: the document it belongs to and
107/// the day it is drawn on.
108#[derive(Debug, Clone, PartialEq, Eq, Default)]
109pub struct FrameContext {
110    /// The open document's name, as the app's tab and every other export mean
111    /// it; empty when the document has never been named.
112    pub document: String,
113    /// `YYYY-MM-DD`, from [`today_iso`].
114    pub date: String,
115}
116
117/// The border, title block and revision table, drawn — polylines and centred
118/// text runs in paper millimetres. Absent from a sheet that draws none of them.
119#[derive(Debug, Clone, PartialEq, serde::Serialize)]
120pub struct FrameDrawing {
121    /// The border rectangle and the title block's cell rules, each a polyline.
122    pub lines: Vec<Vec<[f64; 2]>>,
123    pub texts: Vec<SheetText>,
124    /// The revision table, when the sheet has revisions and room for them. A
125    /// piece of its own rather than more `lines` and `texts`, so the SVG can
126    /// group it and a reader can find its bounds.
127    #[serde(rename = "revisionTable", skip_serializing_if = "Option::is_none")]
128    pub revision_table: Option<RevisionTableDrawing>,
129}
130
131/// The revision table: its rules, its header and row text, and where it sits.
132#[derive(Debug, Clone, PartialEq, serde::Serialize)]
133pub struct RevisionTableDrawing {
134    pub lines: Vec<Vec<[f64; 2]>>,
135    /// The header's three labels, then each row's cells, in row order. A blank
136    /// cell writes no run, as a blank title-block value does.
137    pub texts: Vec<SheetText>,
138    /// How many revision rows it draws, header not counted.
139    pub rows: usize,
140    /// `[x0, y0, x1, y1]`, paper millimetres.
141    pub bounds: [f64; 4],
142    /// Where it went: `beside` the title block, `above` it, or in the `corner`
143    /// of a sheet with no title block.
144    pub placement: String,
145}
146
147impl FrameDrawing {
148    fn is_empty(&self) -> bool {
149        self.lines.is_empty() && self.texts.is_empty() && self.revision_table.is_none()
150    }
151}
152
153/// Build the frame of `sheet`. `None` when the sheet draws none of its pieces.
154pub fn frame(sheet: &Sheet, context: &FrameContext) -> Option<FrameDrawing> {
155    let (w, h) = sheet.millimetres();
156    let inset = sheet.border_inset();
157    let mut out = FrameDrawing { lines: Vec::new(), texts: Vec::new(), revision_table: None };
158    if sheet.border {
159        // A closed rectangle as ONE polyline, back to its first corner: the
160        // frame is lines like everything else on the paper, not a `<rect>` the
161        // PDF writer would have to special-case.
162        let (x0, y0, x1, y1) = (inset, inset, w - inset, h - inset);
163        if x1 > x0 && y1 > y0 {
164            out.lines.push(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]);
165        }
166    }
167    if sheet.title_block {
168        title_block(sheet, context, w, h, inset, &mut out);
169    }
170    out.revision_table = revision_table(sheet, w, h, inset);
171    (!out.is_empty()).then_some(out)
172}
173
174/// The stamp's scale factor on a `w × h` sheet at `inset`: 1 wherever it fits,
175/// less on a sheet too small to carry it. The revision table is drawn at the
176/// SAME factor, so the two read as one piece of furniture.
177fn stamp_scale(w: f64, h: f64, inset: f64) -> f64 {
178    let usable_w = (w - 2.0 * inset).max(0.0);
179    let usable_h = (h - 2.0 * inset).max(0.0);
180    (usable_w / TITLE_WIDTH_MM).min(usable_h / title_height_mm()).min(1.0)
181}
182
183/// The revision table, placed. `None` for an empty list, or a sheet with no
184/// room for it — see the module header for where it goes.
185fn revision_table(sheet: &Sheet, w: f64, h: f64, inset: f64) -> Option<RevisionTableDrawing> {
186    if sheet.revisions.is_empty() {
187        return None;
188    }
189    let scale = stamp_scale(w, h, inset);
190    if scale < SCALE_FLOOR {
191        return None;
192    }
193    let table_w = TITLE_WIDTH_MM * scale;
194    let table_h = (REVISION_HEADER_MM + REVISION_ROW_MM * sheet.revisions.len() as f64) * scale;
195    let (left, top) = (inset, inset);
196    let (right, bottom) = (w - inset, h - inset);
197    // Where it can go, in order: to the stamp's left on the bottom margin; on
198    // top of the stamp; or, with no stamp at all, in the corner.
199    let (x1, y1, placement) = if sheet.title_block {
200        let stamp_left = right - TITLE_WIDTH_MM * scale;
201        let stamp_top = bottom - title_height_mm() * scale;
202        if stamp_left - table_w >= left - 1e-9 && bottom - table_h >= top - 1e-9 {
203            (stamp_left, bottom, "beside")
204        } else if stamp_top - table_h >= top - 1e-9 {
205            (right, stamp_top, "above")
206        } else {
207            return None;
208        }
209    } else if bottom - table_h >= top - 1e-9 {
210        (right, bottom, "corner")
211    } else {
212        return None;
213    };
214    let (x0, y0) = (x1 - table_w, y1 - table_h);
215
216    let mut lines = vec![vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]];
217    // A rule under the header and one between each pair of rows…
218    let header = REVISION_HEADER_MM * scale;
219    let row = REVISION_ROW_MM * scale;
220    for k in 0..sheet.revisions.len() {
221        let y = y0 + header + row * k as f64;
222        lines.push(vec![[x0, y], [x1, y]]);
223    }
224    // …and the two column rules, the table's whole height.
225    let mut columns = vec![x0];
226    for width in REVISION_COLUMNS_MM {
227        columns.push(columns[columns.len() - 1] + width * scale);
228    }
229    for x in &columns[1..3] {
230        lines.push(vec![[*x, y0], [*x, y1]]);
231    }
232
233    let pad = CELL_PAD_MM * scale;
234    let mut table = FrameDrawing { lines: Vec::new(), texts: Vec::new(), revision_table: None };
235    for (k, label) in ["REV", "DATE", "DESCRIPTION"].into_iter().enumerate() {
236        push_text(&mut table, label, columns[k] + pad, columns[k + 1] - pad, y0, y0 + header, LABEL_HEIGHT_MM * scale);
237    }
238    for (index, revision) in sheet.revisions.iter().enumerate() {
239        let (top, bottom) = (y0 + header + row * index as f64, y0 + header + row * (index + 1) as f64);
240        for (k, value) in [&revision.rev, &revision.date, &revision.description].into_iter().enumerate() {
241            push_text(&mut table, value, columns[k] + pad, columns[k + 1] - pad, top, bottom, REVISION_TEXT_MM * scale);
242        }
243    }
244    Some(RevisionTableDrawing {
245        lines,
246        texts: table.texts,
247        rows: sheet.revisions.len(),
248        bounds: [x0, y0, x1, y1],
249        placement: placement.to_string(),
250    })
251}
252
253/// The stamp in the bottom-right corner, inside the border.
254fn title_block(
255    sheet: &Sheet,
256    context: &FrameContext,
257    w: f64,
258    h: f64,
259    inset: f64,
260    out: &mut FrameDrawing,
261) {
262    let scale = stamp_scale(w, h, inset);
263    if scale < SCALE_FLOOR {
264        return;
265    }
266    let block_w = TITLE_WIDTH_MM * scale;
267    let rows: Vec<f64> = ROW_HEIGHTS_MM.iter().map(|mm| mm * scale).collect();
268    let block_h: f64 = rows.iter().sum();
269    // Bottom-right, against the border.
270    let x0 = w - inset - block_w;
271    let y0 = h - inset - block_h;
272    let x1 = x0 + block_w;
273    let y1 = y0 + block_h;
274
275    out.lines.push(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]);
276    let mut y = y0;
277    let mut edges = Vec::new();
278    for row in &rows {
279        edges.push(y);
280        y += row;
281        if y < y1 - 1e-9 {
282            out.lines.push(vec![[x0, y], [x1, y]]);
283        }
284    }
285    edges.push(y1);
286    // The one vertical rule: SCALE | DATE.
287    let split = x0 + block_w * SPLIT;
288    out.lines.push(vec![[split, edges[2]], [split, edges[3]]]);
289
290    let pad = CELL_PAD_MM * scale;
291    let mut cell = |x_min: f64, x_max: f64, y_min: f64, y_max: f64, label: &str, value: &str, value_height: f64| {
292        let band = (y_max - y_min) * LABEL_BAND;
293        push_text(out, label, x_min + pad, x_max - pad, y_min, y_min + band, LABEL_HEIGHT_MM * scale);
294        push_text(out, value, x_min + pad, x_max - pad, y_min + band, y_max, value_height * scale);
295    };
296    cell(x0, x1, edges[0], edges[1], "SHEET", &sheet.name, TITLE_HEIGHT_MM);
297    cell(x0, x1, edges[1], edges[2], "DOCUMENT", &context.document, VALUE_HEIGHT_MM);
298    cell(x0, split, edges[2], edges[3], "SCALE", &scale_text(sheet), VALUE_HEIGHT_MM);
299    cell(split, x1, edges[2], edges[3], "DATE", &context.date, VALUE_HEIGHT_MM);
300    cell(x0, x1, edges[3], edges[4], "NOTES", &sheet.notes, VALUE_HEIGHT_MM);
301}
302
303/// One text run, centred in the box `[x_min, x_max] × [y_min, y_max]`. Nothing
304/// is pushed for an empty string: an unnamed document or an empty note leaves
305/// its cell blank rather than carrying a run with no characters in it.
306fn push_text(
307    out: &mut FrameDrawing,
308    text: &str,
309    x_min: f64,
310    x_max: f64,
311    y_min: f64,
312    y_max: f64,
313    height: f64,
314) {
315    let text = text.trim();
316    if text.is_empty() || x_max <= x_min {
317        return;
318    }
319    out.texts.push(SheetText::flat(
320        text.to_string(),
321        [(x_min + x_max) * 0.5, (y_min + y_max) * 0.5],
322        height,
323    ));
324}
325
326/// The FIRST placed view's scale as a ratio — `4:1`, `1:2`, `2.5:1` — or an em
327/// dash on a sheet with nothing placed on it yet.
328pub fn scale_text(sheet: &Sheet) -> String {
329    match sheet.views.first() {
330        Some(view) => ratio_text(view.scale),
331        None => "\u{2014}".to_string(),
332    }
333}
334
335/// A scale in paper millimetres per model unit as a drawing ratio — `4:1`,
336/// `1:2`, `2.5:1` — or an em dash for one that is not a scale. The title
337/// block's SCALE cell and a detail view's caption both read it.
338pub fn ratio_text(scale: f64) -> String {
339    if !scale.is_finite() || scale <= 0.0 {
340        return "\u{2014}".to_string();
341    }
342    if scale >= 1.0 {
343        format!("{}:1", trim(scale))
344    } else {
345        format!("1:{}", trim(1.0 / scale))
346    }
347}
348
349/// `4` rather than `4.000`, `2.5` as `2.5`.
350fn trim(value: f64) -> String {
351    let text = format!("{value:.3}");
352    let text = text.trim_end_matches('0').trim_end_matches('.');
353    if text.is_empty() { "0".to_string() } else { text.to_string() }
354}
355
356/// Today, `YYYY-MM-DD`, in UTC.
357///
358/// UTC on both shells rather than local time on one of them: the same sheet
359/// exported from the browser and from a headless host must carry the same date,
360/// and the alternative is a native build with no time zone database pretending
361/// to know one.
362pub fn today_iso() -> String {
363    iso_date(now_unix_seconds())
364}
365
366/// A unix timestamp as `YYYY-MM-DD` (UTC).
367pub fn iso_date(unix_seconds: i64) -> String {
368    let (year, month, day) = civil_from_days(unix_seconds.div_euclid(86_400));
369    format!("{year:04}-{month:02}-{day:02}")
370}
371
372/// Days since 1970-01-01 as a civil date — Howard Hinnant's `civil_from_days`,
373/// exact for every day in a proleptic Gregorian calendar and the reason this
374/// file needs no date crate.
375pub fn civil_from_days(days: i64) -> (i64, u32, u32) {
376    // Shift the era so the leap day lands at the END of a 400-year cycle.
377    let z = days + 719_468;
378    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
379    let day_of_era = (z - era * 146_097) as i64; // [0, 146_096]
380    let year_of_era =
381        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; // [0, 399]
382    let year = year_of_era + era * 400;
383    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); // [0, 365]
384    let month_prime = (5 * day_of_year + 2) / 153; // [0, 11] with March = 0
385    let day = (day_of_year - (153 * month_prime + 2) / 5 + 1) as u32; // [1, 31]
386    let month = if month_prime < 10 { month_prime + 3 } else { month_prime - 9 } as u32;
387    (year + i64::from(month <= 2), month, day)
388}
389
390#[cfg(not(target_arch = "wasm32"))]
391fn now_unix_seconds() -> i64 {
392    std::time::SystemTime::now()
393        .duration_since(std::time::UNIX_EPOCH)
394        .map(|since| since.as_secs() as i64)
395        .unwrap_or(0)
396}
397
398/// The browser's clock. `std::time::SystemTime::now()` panics on
399/// `wasm32-unknown-unknown`, which is why this is the one place in the crate
400/// that asks JS for the time.
401#[cfg(target_arch = "wasm32")]
402fn now_unix_seconds() -> i64 {
403    (js_sys::Date::now() / 1000.0) as i64
404}
405
406