Skip to main content

brep_render/sheets/
print.rs

1//! Manufacturing instructions on paper: the printed pages and the per-step
2//! pictures, drawn as sheets.
3//!
4//! [`brep_instructions::layout`] says WHERE everything goes on each page; this
5//! module fills those places with what the sheet writers already draw —
6//! polylines and centred text runs in paper millimetres, plus each step's view
7//! projected as a placed view ([`ViewDrawing`]) — so the PDF and the SVG are
8//! [`super::pdf::write_pdf`] and [`super::svg::to_svg`] unchanged. The
9//! projections come from the engine, which owns the scene: it asks
10//! [`picture_rects`] where each picture goes, fits a view into each with
11//! [`fit`], and hands the drawings back to [`pages`] or [`step_image`].
12
13use super::frame::FrameDrawing;
14use super::pdf::text_width;
15use super::project::{SheetDrawing, SheetText, ViewDrawing};
16use super::svg::CAP_PER_EM;
17use brep_instructions::layout::{Page, PageKind, Rect, HEADER_MM, MARGIN_MM, PARTS_ROW_MM, TOOLS_ROW_MM};
18use brep_instructions::model::{Instructions, Step};
19use std::collections::HashMap;
20
21/// Body text's cap height, in millimetres.
22const BODY_MM: f64 = 2.8;
23/// A heading's cap height.
24const HEADING_MM: f64 = 4.2;
25/// Line pitch over cap height.
26const LEADING: f64 = 1.75;
27/// The share of a picture rectangle a fitted view fills.
28const FILL: f64 = 0.9;
29/// The cover's title block above its picture.
30const COVER_TEXT_MM: f64 = 46.0;
31/// A per-step picture: its paper and the caption band above the view.
32pub const IMAGE_MM: (f64, f64) = (160.0, 120.0);
33const IMAGE_CAPTION_MM: f64 = 10.0;
34
35/// A run's width in millimetres at cap height `cap`.
36fn width_mm(text: &str, cap: f64) -> f64 {
37    text_width(text) * cap / CAP_PER_EM
38}
39
40/// A run reading left to right whose line box starts at `(x, y)` (top-left).
41/// The writers centre a run on its anchor, so the anchor is the box's middle.
42fn left(text: impl Into<String>, x: f64, y: f64, cap: f64) -> SheetText {
43    let text = text.into();
44    let w = width_mm(&text, cap);
45    SheetText::flat(text, [x + w / 2.0, y + cap / 2.0], cap)
46}
47
48/// [`left`], ending at `x`.
49fn right(text: impl Into<String>, x: f64, y: f64, cap: f64) -> SheetText {
50    let text = text.into();
51    let w = width_mm(&text, cap);
52    SheetText::flat(text, [x - w / 2.0, y + cap / 2.0], cap)
53}
54
55/// `text` broken into lines no wider than `width` at cap height `cap`, at
56/// spaces; a word wider than the line stands alone on one.
57pub fn wrap(text: &str, width: f64, cap: f64) -> Vec<String> {
58    let mut lines = Vec::new();
59    for paragraph in text.lines() {
60        let mut line = String::new();
61        for word in paragraph.split_whitespace() {
62            let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
63            if !line.is_empty() && width_mm(&candidate, cap) > width {
64                lines.push(std::mem::replace(&mut line, word.to_string()));
65            } else {
66                line = candidate;
67            }
68        }
69        lines.push(line);
70    }
71    lines
72}
73
74fn rect_lines(r: Rect) -> Vec<[f64; 2]> {
75    vec![[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h], [r.x, r.y]]
76}
77
78/// A column of text lines from the top of `area` down; a line that would
79/// run past its bottom is left out and the last one kept reads `…`.
80struct Column {
81    area: Rect,
82    y: f64,
83    texts: Vec<SheetText>,
84    full: bool,
85}
86
87impl Column {
88    fn new(area: Rect) -> Self {
89        Column { area, y: area.y, texts: Vec::new(), full: false }
90    }
91
92    fn line(&mut self, text: impl Into<String>, cap: f64, indent: f64) {
93        if self.full {
94            return;
95        }
96        let pitch = cap * LEADING;
97        if self.y + pitch > self.area.y + self.area.h + 1e-9 {
98            self.full = true;
99            if let Some(last) = self.texts.pop() {
100                let x = last.anchor[0] - width_mm(&last.text, last.height) / 2.0;
101                let y = last.anchor[1] - last.height / 2.0;
102                self.texts.push(left(format!("{}\u{2026}", last.text), x, y, last.height));
103            }
104            return;
105        }
106        self.texts.push(left(text, self.area.x + indent, self.y, cap));
107        self.y += pitch;
108    }
109
110    fn wrapped(&mut self, text: &str, cap: f64, indent: f64) {
111        for line in wrap(text, self.area.w - indent, cap) {
112            self.line(line, cap, indent);
113        }
114    }
115
116    fn gap(&mut self) {
117        self.y += BODY_MM * 0.6;
118    }
119}
120
121/// What a step's text area says: its number and title, the parts it adds,
122/// its tools and torque, its callouts and its notes.
123fn step_text(step: &Step, number: usize, area: Rect) -> Vec<SheetText> {
124    let mut column = Column::new(area);
125    column.line(format!("Step {number}"), HEADING_MM, 0.0);
126    if !step.title.is_empty() {
127        column.wrapped(&step.title, BODY_MM * 1.15, 0.0);
128    }
129    column.gap();
130    if !step.adds.is_empty() {
131        column.line("Parts", BODY_MM, 0.0);
132        for add in &step.adds {
133            let find = if add.find_number.is_empty() { String::new() } else { format!("({}) ", add.find_number) };
134            column.wrapped(&format!("{find}{} \u{d7} {}", add.quantity(), add.display_name()), BODY_MM, 3.0);
135        }
136        column.gap();
137    }
138    if !step.tools.is_empty() {
139        column.line("Tools", BODY_MM, 0.0);
140        for tool in &step.tools {
141            let text = if tool.spec.is_empty() { tool.name.clone() } else { format!("{} \u{2014} {}", tool.name, tool.spec) };
142            column.wrapped(&text, BODY_MM, 3.0);
143        }
144        column.gap();
145    }
146    if let Some(torque) = &step.torque {
147        column.wrapped(&format!("Torque: {}", torque.text()), BODY_MM, 0.0);
148        column.gap();
149    }
150    for callout in &step.callouts {
151        column.wrapped(&format!("{}: {}", callout.kind.label().to_uppercase(), callout.text), BODY_MM, 0.0);
152    }
153    if !step.callouts.is_empty() {
154        column.gap();
155    }
156    if !step.notes.is_empty() {
157        column.wrapped(&step.notes, BODY_MM, 0.0);
158    }
159    column.texts
160}
161
162/// The document's title as the pages print it.
163pub fn title(block: &Instructions) -> String {
164    let title = block.front.title.trim();
165    if !title.is_empty() {
166        return title.to_string();
167    }
168    let name = block.target.name.trim();
169    if name.is_empty() { "Assembly instructions".into() } else { format!("{name} assembly instructions") }
170}
171
172/// The step a page's cover shows: the front matter's, else the last.
173pub fn cover_step(block: &Instructions) -> Option<String> {
174    block.front.cover_step.clone().or_else(|| block.steps().last().map(|(_, step)| step.id.clone()))
175}
176
177/// Where a picture goes, keyed by what it shows: `cover` for the cover's,
178/// else `(page index, slot index)` for a step slot's.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180pub enum PictureAt {
181    Cover,
182    Slot(usize, usize),
183}
184
185/// Every picture the pages draw, with the step whose view it is and the
186/// rectangle it is fitted into.
187pub fn picture_rects(block: &Instructions, pages: &[Page]) -> Vec<(PictureAt, String, Rect)> {
188    let mut out = Vec::new();
189    for (index, page) in pages.iter().enumerate() {
190        match page.kind {
191            PageKind::Cover => {
192                if let Some(step) = cover_step(block) {
193                    let c = page.content;
194                    out.push((PictureAt::Cover, step, Rect { x: c.x, y: c.y + COVER_TEXT_MM, w: c.w, h: c.h - COVER_TEXT_MM }));
195                }
196            }
197            PageKind::Steps => {
198                for (slot_index, slot) in page.slots.iter().enumerate() {
199                    out.push((PictureAt::Slot(index, slot_index), slot.step_id.clone(), slot.picture));
200                }
201            }
202            _ => {}
203        }
204    }
205    out
206}
207
208/// The placement `(position, scale)` that fits a view into `rect`, from its
209/// drawing at position `[0, 0]` and scale 1 (`bounds` = that drawing's
210/// `[x0, y0, x1, y1]`). Text keeps its paper size, so this is the fit of the
211/// lines; the margin `FILL` leaves takes up the balloons' slack.
212pub fn fit(bounds: [f64; 4], rect: Rect) -> ([f64; 2], f64) {
213    let (w, h) = (bounds[2] - bounds[0], bounds[3] - bounds[1]);
214    let scale = if w > 1e-9 && h > 1e-9 {
215        (rect.w / w).min(rect.h / h) * FILL
216    } else if w > 1e-9 {
217        rect.w / w * FILL
218    } else if h > 1e-9 {
219        rect.h / h * FILL
220    } else {
221        1.0
222    };
223    let centre = [(bounds[0] + bounds[2]) / 2.0, (bounds[1] + bounds[3]) / 2.0];
224    ([rect.x + rect.w / 2.0 - centre[0] * scale, rect.y + rect.h / 2.0 - centre[1] * scale], scale)
225}
226
227/// The printed pages as sheets, in print order: each page's header (the
228/// title, and the page number of the count), then its own content — the
229/// cover's title, assembly and picture; a parts or tools list's rows; the
230/// step slots, each framed, its picture in `pictures` and its text beside or
231/// below. A picture missing from `pictures` leaves its place empty.
232pub fn pages(block: &Instructions, layout: &[Page], pictures: &HashMap<PictureAt, ViewDrawing>) -> Vec<SheetDrawing> {
233    let title = title(block);
234    let count = layout.len();
235    let parts = block.parts_list();
236    let tools = block.tools_list();
237    layout
238        .iter()
239        .enumerate()
240        .map(|(index, page)| {
241            let mut lines: Vec<Vec<[f64; 2]>> = Vec::new();
242            let mut texts: Vec<SheetText> = Vec::new();
243            let mut views: Vec<ViewDrawing> = Vec::new();
244            let c = page.content;
245            // The header band: title left, page number right, a rule below.
246            let header_y = MARGIN_MM + (HEADER_MM - BODY_MM * 1.2) / 2.0 - 1.0;
247            let section = match page.kind {
248                PageKind::Cover => String::new(),
249                PageKind::PartsList { .. } => " \u{2014} Parts list".into(),
250                PageKind::ToolsList { .. } => " \u{2014} Tools".into(),
251                PageKind::Steps => String::new(),
252            };
253            texts.push(left(format!("{title}{section}"), c.x, header_y, BODY_MM * 1.2));
254            texts.push(right(format!("Page {} of {count}", page.number), c.x + c.w, header_y, BODY_MM * 1.2));
255            lines.push(vec![[c.x, c.y - 2.0], [c.x + c.w, c.y - 2.0]]);
256            match page.kind {
257                PageKind::Cover => {
258                    let mut column = Column::new(Rect { h: COVER_TEXT_MM, ..c });
259                    for line in wrap(&title, c.w, 8.0) {
260                        column.line(line, 8.0, 0.0);
261                    }
262                    if !block.front.subtitle.is_empty() {
263                        column.wrapped(&block.front.subtitle, 4.5, 0.0);
264                    }
265                    column.gap();
266                    let target = &block.target;
267                    let mut assembly = format!("Assembly: {}", if target.name.is_empty() { "-" } else { &target.name });
268                    if !target.part_number.is_empty() {
269                        assembly.push_str(&format!(" {}", target.part_number));
270                    }
271                    if !target.revision.is_empty() {
272                        assembly.push_str(&format!(" rev {}", target.revision));
273                    }
274                    column.wrapped(&assembly, BODY_MM * 1.2, 0.0);
275                    column.line(format!("{} steps", block.step_count()), BODY_MM * 1.2, 0.0);
276                    texts.extend(column.texts);
277                    if let Some(view) = pictures.get(&PictureAt::Cover) {
278                        views.push(view.clone());
279                    }
280                }
281                PageKind::PartsList { first, count } => {
282                    let columns = [c.x, c.x + 24.0, c.x + c.w - 48.0, c.x + c.w - 30.0];
283                    let row = |r: usize| c.y + r as f64 * PARTS_ROW_MM + (PARTS_ROW_MM - BODY_MM) / 2.0;
284                    for (x, label) in columns.iter().zip(["Find No.", "Part", "Qty", "First step"]) {
285                        texts.push(left(label, *x, row(0), BODY_MM));
286                    }
287                    for (r, item) in parts.iter().skip(first).take(count).enumerate() {
288                        let step = block.step_number(&item.first_step).map(|n| n.to_string()).unwrap_or_default();
289                        let name = if item.subassembly { format!("{} (sub-assembly)", item.name) } else { item.name.clone() };
290                        let cells = [item.find_number.clone(), name, item.quantity.to_string(), step];
291                        for (x, cell) in columns.iter().zip(cells) {
292                            if !cell.is_empty() {
293                                texts.push(left(cell, *x, row(r + 1), BODY_MM));
294                            }
295                        }
296                    }
297                    for r in 1..=count + 1 {
298                        let y = c.y + r as f64 * PARTS_ROW_MM;
299                        lines.push(vec![[c.x, y], [c.x + c.w, y]]);
300                    }
301                }
302                PageKind::ToolsList { first, count } => {
303                    let columns = [c.x, c.x + c.w * 0.4, c.x + c.w - 30.0];
304                    let row = |r: usize| c.y + r as f64 * TOOLS_ROW_MM + (TOOLS_ROW_MM - BODY_MM) / 2.0;
305                    for (x, label) in columns.iter().zip(["Tool", "Spec", "Steps"]) {
306                        texts.push(left(label, *x, row(0), BODY_MM));
307                    }
308                    for (r, (tool, steps)) in tools.iter().skip(first).take(count).enumerate() {
309                        let numbers: Vec<String> =
310                            steps.iter().filter_map(|id| block.step_number(id)).map(|n| n.to_string()).collect();
311                        let cells = [tool.name.clone(), tool.spec.clone(), numbers.join(", ")];
312                        for (x, cell) in columns.iter().zip(cells) {
313                            if !cell.is_empty() {
314                                texts.push(left(cell, *x, row(r + 1), BODY_MM));
315                            }
316                        }
317                    }
318                    for r in 1..=count + 1 {
319                        let y = c.y + r as f64 * TOOLS_ROW_MM;
320                        lines.push(vec![[c.x, y], [c.x + c.w, y]]);
321                    }
322                }
323                PageKind::Steps => {
324                    for (slot_index, slot) in page.slots.iter().enumerate() {
325                        lines.push(rect_lines(slot.rect));
326                        if let Some(view) = pictures.get(&PictureAt::Slot(index, slot_index)) {
327                            views.push(view.clone());
328                        }
329                        if let Some(step) = block.step(&slot.step_id) {
330                            let inset = Rect { x: slot.text.x + 3.0, y: slot.text.y + 3.0, w: slot.text.w - 6.0, h: slot.text.h - 6.0 };
331                            texts.extend(step_text(step, slot.step_number, inset));
332                        }
333                    }
334                }
335            }
336            SheetDrawing {
337                id: format!("P{}", page.number),
338                name: format!("Page {}", page.number),
339                width_mm: page.width_mm,
340                height_mm: page.height_mm,
341                views,
342                dimensions: Vec::new(),
343                ordinates: Vec::new(),
344                frame: Some(FrameDrawing { lines, texts, revision_table: None }),
345            }
346        })
347        .collect()
348}
349
350/// Where a per-step picture's view goes on its [`IMAGE_MM`] paper.
351pub fn image_picture_rect() -> Rect {
352    let m = 4.0;
353    Rect { x: m, y: IMAGE_CAPTION_MM, w: IMAGE_MM.0 - 2.0 * m, h: IMAGE_MM.1 - IMAGE_CAPTION_MM - m }
354}
355
356/// One step's picture as a sheet: its number and title over its view.
357pub fn step_image(block: &Instructions, step_id: &str, picture: Option<ViewDrawing>) -> Result<SheetDrawing, String> {
358    let step = block.step(step_id).ok_or_else(|| format!("no step '{step_id}'"))?;
359    let number = block.step_number(step_id).unwrap_or(0);
360    let caption = if step.title.is_empty() { format!("Step {number}") } else { format!("Step {number}  {}", step.title) };
361    let texts = vec![left(caption, 4.0, (IMAGE_CAPTION_MM - HEADING_MM) / 2.0, HEADING_MM)];
362    Ok(SheetDrawing {
363        id: step.id.clone(),
364        name: format!("Step {number}"),
365        width_mm: IMAGE_MM.0,
366        height_mm: IMAGE_MM.1,
367        views: picture.into_iter().collect(),
368        dimensions: Vec::new(),
369        ordinates: Vec::new(),
370        frame: Some(FrameDrawing { lines: Vec::new(), texts, revision_table: None }),
371    })
372}
373