1use 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
21const BODY_MM: f64 = 2.8;
23const HEADING_MM: f64 = 4.2;
25const LEADING: f64 = 1.75;
27const FILL: f64 = 0.9;
29const COVER_TEXT_MM: f64 = 46.0;
31pub const IMAGE_MM: (f64, f64) = (160.0, 120.0);
33const IMAGE_CAPTION_MM: f64 = 10.0;
34
35fn width_mm(text: &str, cap: f64) -> f64 {
37 text_width(text) * cap / CAP_PER_EM
38}
39
40fn 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
48fn 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
55pub 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
78struct 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
121fn 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
162pub 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
172pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180pub enum PictureAt {
181 Cover,
182 Slot(usize, usize),
183}
184
185pub 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
208pub 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
227pub 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 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
350pub 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
356pub 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