Skip to main content

brep_render/engine_state/
instructions_export.rs

1//! Manufacturing instructions out of the app: the printed PDF, each step's
2//! picture as PNG and SVG, and the interactive player's data and offline
3//! `.html`.
4//!
5//! The PDF and the SVG are sheets ([`crate::sheets::print`]): each step's view
6//! is projected as a placed view, hidden lines removed, exactly as a drawing
7//! sheet draws a PMI view — by the exact pass over the solids' B-reps, which
8//! the export asks the runner for first ([`EngineState::instructions_topology_ready`]),
9//! as a sheet export does. A B-rep that has not arrived leaves its step
10//! pictures to the mesh approximation, and the file says so
11//! (`data-hidden-line` on the SVG's view, `/HiddenLine` in the PDF's Info).
12//! The PNG is the thumbnail rasterizer from the step's camera, the parts
13//! earlier steps placed drawn grey. None of them reads what the viewport
14//! shows: a step shown in the player, its ghosts and an insertion half way
15//! home do not change a file.
16//!
17//! The player data carries one mesh per part, each node with its placement,
18//! each step's camera, picture and view PMI (laid out as the 3D PDF lays it
19//! out, [`crate::sheets::pdf3d::view_annotations`]), and the assembly's tree
20//! with a thumbnail per part (the page's Model and Parts tabs).
21
22use super::{component_move::now_ms, EngineState};
23use crate::scene::SolidDisplay;
24use brep_instructions::wizard;
25use crate::sheets::print::{self, PictureAt};
26use crate::sheets::project::{poses_of, project_view_through, to_paper, Deferred, LineArtLayer, LinesMode, Model, Unposed, ViewDrawing, ViewFrame};
27use crate::sheets::PlacedView;
28use crate::thumbnail;
29use crate::thumbnail::line_art;
30use brep_instructions::edit;
31use brep_instructions::model::{Arrow, Style};
32use brep_instructions::layout::{self, Rect};
33use brep_instructions::player::{self, Mode, PlayerData, PlayerMesh, PlayerNode};
34use brep_kernel::PmiProjection;
35use brep_kernel::PmiView;
36use serde_json::json;
37use std::collections::HashMap;
38
39/// A per-step PNG's edge, in pixels.
40pub const IMAGE_PX: u32 = 800;
41
42/// The colour a PNG draws a part an earlier step placed in (sRGB 0..1).
43const PLACED_RGB: [f32; 3] = [0.80, 0.81, 0.83];
44
45/// A step picture's edge in the player data, in pixels: the picture the
46/// player shows without WebGL.
47const PLAYER_IMAGE_PX: u32 = 480;
48
49/// A part thumbnail's edge in the player data, in pixels.
50const PLAYER_THUMBNAIL_PX: u32 = 96;
51
52const IDENTITY: [f64; 16] = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
53
54/// How long an export waits for the runner's topology reply before it draws
55/// what it has ([`EngineState::instructions_topology_ready`]).
56pub const TOPOLOGY_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
57
58/// A PDF prepared over successive frames. Heavy exact passes run on the history runner.
59/// Dropping this job cancels delivery; the document stamp prevents mixed-revision output.
60pub struct InstructionsPdfExport {
61    block: brep_instructions::model::Instructions,
62    layout: Vec<layout::Page>,
63    todo: Vec<(PictureAt, String, Rect)>,
64    pictures: HashMap<PictureAt, ViewDrawing>,
65    next: usize,
66    stamp: (u64, u64),
67    started: f64,
68    unposed: Option<Unposed>,
69    views: Vec<PmiView>,
70}
71
72impl InstructionsPdfExport {
73    pub fn progress(&self) -> (usize, usize) { (self.next, self.todo.len()) }
74}
75
76impl EngineState {
77    /// Begin without waiting for topology or projecting any picture.
78    pub fn begin_instructions_pdf(&mut self) -> Result<InstructionsPdfExport, String> {
79        if self.run_pending() { return Err("wait for the model to finish rebuilding before exporting".into()); }
80        let block = self.instructions_required()?;
81        let layout = layout::pages(&block)?;
82        let todo = print::picture_rects(&block, &layout);
83        let names: Vec<String> = self.scene.solids().iter().filter(|solid| !solid.is_sketch).map(|solid| solid.name.clone()).collect();
84        self.request_exact_solids(names.iter().map(String::as_str));
85        Ok(InstructionsPdfExport {
86            block, layout, todo, pictures: HashMap::new(), next: 0,
87            stamp: (self.applied_generation(), self.history_revision()),
88            started: now_ms(), unposed: None, views: self.pmi_state().views,
89        })
90    }
91
92    /// Poll at most one picture per frame. Never wait for the runner or fall back
93    /// to computing hidden lines on the UI thread if the runner refuses a job.
94    pub fn poll_instructions_pdf(&mut self, job: &mut InstructionsPdfExport) -> Result<Option<(Vec<u8>, usize)>, String> {
95        if job.stamp != (self.applied_generation(), self.history_revision()) {
96            return Err("the document changed during PDF export; export again".into());
97        }
98        self.pump_topology();
99        self.pump_sheet_lines();
100        if self.topology_pending() {
101            if now_ms() - job.started >= TOPOLOGY_WAIT.as_secs_f64() * 1000.0 { return Err("timed out loading exact geometry for PDF export".into()); }
102            return Ok(None);
103        }
104        if self.sheet_lines_pending() { return Ok(None); }
105        if job.unposed.is_none() { job.unposed = Some(self.instructions_export_displays()); }
106        if let Some((at, step_id, rect)) = job.todo.get(job.next) {
107            if let Some(view) = job.block.step(step_id).and_then(|step| step.view.as_deref()) {
108                let placed = self.instructions_placed_names(&job.block, step_id);
109                let pending = std::cell::Cell::new(false);
110                // Discover at most one cache miss, then submit it after the scene borrow ends.
111                let mut requests = Vec::new();
112                let mut defer = |request| { requests.push(request); pending.set(true); Deferred::Pending };
113                let arrows = (job.block.template.style == Style::LineArt).then(|| self.instructions_step_arrows(&job.block, step_id));
114                let drawing = self.instructions_project_with(job.unposed.as_ref().unwrap(), &job.views, view, *rect, &placed,
115                    arrows.as_deref(), &mut LinesMode::Export(&mut defer), &pending);
116                for request in requests {
117                    if matches!(self.sheet_lines.defer(self.runner.as_mut(), request), Deferred::Here) {
118                        return Err("the background runner could not draw the PDF picture; export again".into());
119                    }
120                }
121                let Some(drawing) = drawing else { return Ok(None) };
122                if !drawing.error.is_empty() { return Err(drawing.error); }
123                job.pictures.insert(*at, drawing);
124            }
125            job.next += 1;
126            return Ok(None);
127        }
128        let pages = print::pages(&job.block, &job.layout, &job.pictures);
129        let refs = pages.iter().collect::<Vec<_>>();
130        Ok(Some((crate::sheets::pdf::write_pdf(&print::title(&job.block), &refs), pages.len())))
131    }
132
133    /// Ask the runner for the B-rep of every solid the step pictures draw
134    /// (see [`Self::request_exact_solids`]) and wait for the reply, up to
135    /// [`TOPOLOGY_WAIT`], on a runner that can be waited on (the Inline runner
136    /// answers before the request returns; the thread runner is waited on; a
137    /// worker cannot be, and the caller holds the export until
138    /// [`Self::topology_pending`] clears). Returns whether every drawn solid's
139    /// B-rep is in the scene now: the exact pass draws the pictures. Otherwise
140    /// the mesh approximation does, and the file says so.
141    /// Ask for the exact solids the step pictures draw WITHOUT waiting:
142    /// returns whether they are all here already. The frame's `pump` lands the
143    /// replies; a caller that may not block (automation) checks
144    /// [`Self::topology_pending`] on later frames.
145    pub fn instructions_request_topology(&mut self) -> bool {
146        let names: Vec<String> = self.scene.solids().iter().filter(|solid| !solid.is_sketch).map(|solid| solid.name.clone()).collect();
147        self.request_exact_solids(names.iter().map(String::as_str));
148        self.pump_topology();
149        !self.topology_pending()
150    }
151
152    pub fn instructions_topology_ready(&mut self) -> bool {
153        let names: Vec<String> = self.scene.solids().iter().filter(|solid| !solid.is_sketch).map(|solid| solid.name.clone()).collect();
154        self.request_exact_solids(names.iter().map(String::as_str));
155        let deadline = now_ms() + TOPOLOGY_WAIT.as_secs_f64() * 1000.0;
156        while self.topology_pending() {
157            let now = now_ms();
158            if now >= deadline || !self.runner.wait_topology(std::time::Duration::from_secs_f64((deadline - now) / 1000.0)) {
159                break;
160            }
161            self.pump_topology();
162        }
163        !self.topology_pending()
164    }
165
166    /// Every display as the files draw it: unposed (the explode originals of
167    /// the view the viewport shows) and shown, so only each step view's own
168    /// hidden list and poses decide what a picture holds.
169    fn instructions_export_displays(&self) -> Unposed {
170        self.scene
171            .solids()
172            .iter()
173            .map(|solid| {
174                let mut display = self.pmi_explode_originals.get(&solid.name).unwrap_or(solid).clone();
175                display.visible = true;
176                (solid.name.clone(), display)
177            })
178            .collect()
179    }
180
181    /// The PMI view `view_id` projected into `rect`: projected once at scale
182    /// 1 to measure it, then placed at the scale and position that fit it.
183    /// The parts `placed` by earlier steps are drawn in the model stroke and
184    /// the rest, the parts the step adds, in the highlight stroke
185    /// ([`ViewDrawing::highlighted`]). Visibility is decided once over every
186    /// part; a second pass at the same placement with the placed parts hidden
187    /// says which visible runs lie on the added parts' own edges.
188    fn instructions_project(&self, unposed: &Unposed, views: &[PmiView], view_id: &str, rect: Rect, placed: &[String], line_art: Option<&[Arrow]>) -> ViewDrawing {
189        self.instructions_project_with(unposed, views, view_id, rect, placed, line_art, &mut LinesMode::Compute, &std::cell::Cell::new(false))
190            .expect("synchronous projection")
191    }
192
193    /// With `line_art` (the step's arrows, [`Self::instructions_step_arrows`])
194    /// the picture is a Line art one: its frame takes the arrows in, and the
195    /// drawing carries the fills, outlines and arrows ([`LineArtLayer`]).
196    #[allow(clippy::too_many_arguments)]
197    fn instructions_project_with(
198        &self,
199        unposed: &Unposed,
200        views: &[PmiView],
201        view_id: &str,
202        rect: Rect,
203        placed: &[String],
204        line_art: Option<&[Arrow]>,
205        mode: &mut LinesMode,
206        pending: &std::cell::Cell<bool>,
207    ) -> Option<ViewDrawing> {
208        let env = self.sheet_env();
209        let model = Model { scene: &self.scene, unposed, env: &env };
210        let view = views.iter().find(|view| view.id == view_id);
211        let report = self.pmi_report.as_ref().and_then(|report| report.view(view_id));
212        let placement = |position: [f64; 2], scale: f64| -> PlacedView {
213            serde_json::from_value(json!({ "id": format!("IV-{view_id}"), "view": view_id, "position": position, "scale": scale }))
214                .expect("a placement from its id, view, position and scale")
215        };
216        let measured = project_view_through(&model, &placement([0.0, 0.0], 1.0), view, report, None, None, mode);
217        if pending.get() { return None; }
218        if !measured.error.is_empty() {
219            return Some(measured);
220        }
221        let frame = view.and_then(|view| view.camera.as_ref()).map(ViewFrame::of);
222        // A Line art picture's parts, posed as the lines are, and its arrows.
223        let art = match (line_art, &frame) {
224            (Some(arrows), Some(frame)) => Some(line_art_scene(unposed, report, &measured.solids, placed, arrows, frame)),
225            _ => None,
226        };
227        let mut bounds = measured.bounds;
228        if let (Some(art), Some(frame)) = (&art, &frame) {
229            // The frame takes in where the arrows start.
230            for (tail, tip) in &art.arrows {
231                for p in [tail, tip] {
232                    let v = frame.to_view(*p);
233                    let (x, y) = (v[0], -v[1]);
234                    bounds = [bounds[0].min(x), bounds[1].min(y), bounds[2].max(x), bounds[3].max(y)];
235                }
236            }
237        }
238        let (position, scale) = print::fit(bounds, rect);
239        let mut drawing = project_view_through(&model, &placement(position, scale), view, report, None, None, mode);
240        if pending.get() { return None; }
241        let Some(view) = view else { return Some(drawing) };
242        if placed.is_empty() || drawing.edges.is_empty() {
243            // Nothing placed yet: everything drawn is being added.
244            drawing.highlighted = std::mem::take(&mut drawing.edges);
245            if let (Some(art), Some(frame)) = (&art, &frame) {
246                line_art_layer(&mut drawing, art, frame, &placement(position, scale), rect);
247            }
248            return Some(drawing);
249        }
250        // The same view with what earlier steps placed hidden too (a
251        // transient hide step on a copy, never stored).
252        let mut added_only = view.clone();
253        let extra: Vec<String> = placed.iter().filter(|name| !added_only.hides(name)).cloned().collect();
254        added_only.annotations.push(brep_kernel::PmiAnnotation::hide_step("HID-placed", &extra, true));
255        let alone = project_view_through(&model, &placement(position, scale), Some(&added_only), report, None, None, mode);
256        if pending.get() { return None; }
257        if !alone.error.is_empty() { return Some(alone); }
258        let (edges, highlighted) = split_highlighted(std::mem::take(&mut drawing.edges), &alone.edges);
259        drawing.edges = edges;
260        drawing.highlighted = highlighted;
261        if let (Some(art), Some(frame)) = (&art, &frame) {
262            line_art_layer(&mut drawing, art, frame, &placement(position, scale), rect);
263        }
264        Some(drawing)
265    }
266
267    /// The parts earlier steps placed, by solid name, for `step_id`'s picture.
268    fn instructions_placed_names(&self, block: &brep_instructions::model::Instructions, step_id: &str) -> Vec<String> {
269        let names: Vec<String> = self.scene.solids().iter().map(|solid| solid.name.clone()).collect();
270        edit::presentation(block, step_id, &names).map(|presentation| presentation.placed).unwrap_or_default()
271    }
272
273    /// The instructions as one PDF: the cover, the parts list, the tools list
274    /// and the steps on the document's page template, the pictures drawn by
275    /// the exact pass ([`Self::instructions_topology_ready`]). Returns the
276    /// bytes and the page count.
277    pub fn instructions_pdf(&mut self) -> Result<(Vec<u8>, usize), String> {
278        let block = self.instructions_required()?;
279        self.instructions_topology_ready();
280        let layout = layout::pages(&block)?;
281        let pmi = self.pmi_state();
282        let unposed = self.instructions_export_displays();
283        let mut pictures: HashMap<PictureAt, ViewDrawing> = HashMap::new();
284        for (at, step_id, rect) in print::picture_rects(&block, &layout) {
285            if let Some(view) = block.step(&step_id).and_then(|step| step.view.clone()) {
286                let placed = self.instructions_placed_names(&block, &step_id);
287                let arrows = (block.template.style == Style::LineArt).then(|| self.instructions_step_arrows(&block, &step_id));
288                pictures.insert(at, self.instructions_project(&unposed, &pmi.views, &view, rect, &placed, arrows.as_deref()));
289            }
290        }
291        let pages = print::pages(&block, &layout, &pictures);
292        let refs: Vec<&crate::sheets::project::SheetDrawing> = pages.iter().collect();
293        Ok((crate::sheets::pdf::write_pdf(&print::title(&block), &refs), pages.len()))
294    }
295
296    /// One step's picture as SVG: its number and title over its view, hidden
297    /// lines removed by the exact pass ([`Self::instructions_topology_ready`]).
298    pub fn instructions_step_svg(&mut self, step_id: &str) -> Result<String, String> {
299        let block = self.instructions_required()?;
300        let step = block.step(step_id).ok_or_else(|| format!("no step '{step_id}'"))?;
301        self.instructions_topology_ready();
302        let placed = self.instructions_placed_names(&block, step_id);
303        let arrows = (block.template.style == Style::LineArt).then(|| self.instructions_step_arrows(&block, step_id));
304        let picture = step.view.as_deref().map(|view| {
305            self.instructions_project(&self.instructions_export_displays(), &self.pmi_state().views, view, print::image_picture_rect(), &placed, arrows.as_deref())
306        });
307        Ok(crate::sheets::svg::to_svg(&print::step_image(&block, step_id, picture)?))
308    }
309
310    /// One step's picture as a `size`-pixel square PNG from its view's camera:
311    /// what the step adds in its colours with its edges, what earlier steps
312    /// placed in grey, what comes later left out, each part where the view's
313    /// explode rows put it.
314    pub fn instructions_step_png(&self, step_id: &str, size: u32) -> Result<Vec<u8>, String> {
315        let block = self.instructions_required()?;
316        let step = block.step(step_id).ok_or_else(|| format!("no step '{step_id}'"))?;
317        let view_id = step.view.as_deref().ok_or_else(|| format!("step '{step_id}' has no view: run the wizard or pick one"))?;
318        let pmi = self.pmi_state();
319        let view = pmi.views.iter().find(|view| view.id == view_id).ok_or_else(|| format!("no PMI view '{view_id}'"))?;
320        let camera = view.camera.as_ref().ok_or_else(|| format!("PMI view '{view_id}' has no captured camera"))?;
321        let frame = ViewFrame::of(camera);
322        let back = frame.forward.map(|c| -c);
323        let report = self.pmi_report.as_ref().and_then(|report| report.view(view_id));
324        let names: Vec<String> = self.scene.solids().iter().map(|solid| solid.name.clone()).collect();
325        let presentation = edit::presentation(&block, step_id, &names)?;
326        let unposed = self.instructions_export_displays();
327        if block.template.style == Style::LineArt {
328            let mut art = line_art::LineArt::default();
329            let mut bounds: HashMap<&str, ([f64; 3], [f64; 3])> = HashMap::new();
330            for name in &names {
331                if view.hides(name) || presentation.hidden.contains(name) {
332                    continue;
333                }
334                let Some(display) = unposed.get(name).filter(|display| !display.is_sketch) else { continue };
335                let Some(body) = posed_body(display, &poses_of(report, name), false) else { continue };
336                if presentation.placed.contains(name) {
337                    art.placed.push(body);
338                } else {
339                    bounds.insert(name.as_str(), body_bounds(&body));
340                    art.adding.push(body);
341                }
342            }
343            // Each arrow over the seated parts it moves, from where they start.
344            for arrow in self.instructions_step_arrows(&block, step_id) {
345                let mut lo = [f64::INFINITY; 3];
346                let mut hi = [f64::NEG_INFINITY; 3];
347                for (name, (a, b)) in &bounds {
348                    if under(name, &arrow.occurrence) {
349                        lo = std::array::from_fn(|k| lo[k].min(a[k]));
350                        hi = std::array::from_fn(|k| hi[k].max(b[k]));
351                    }
352                }
353                if lo[0] <= hi[0] {
354                    art.arrows.extend(line_art::still_arrow(lo, hi, arrow.start_offset()));
355                }
356            }
357            return line_art::render_png(&art, size, (frame.right, frame.up, back))
358                .ok_or_else(|| format!("step '{step_id}' shows nothing to draw"));
359        }
360        let mut capture = thumbnail::Capture::default();
361        for name in &names {
362            if view.hides(name) || presentation.hidden.contains(name) {
363                continue;
364            }
365            let Some(display) = unposed.get(name).filter(|display| !display.is_sketch) else { continue };
366            let Some(body) = posed_body(display, &poses_of(report, name), presentation.placed.contains(name)) else { continue };
367            capture.bodies.push(body);
368        }
369        thumbnail::render_png_from(&capture, size, (frame.right, frame.up, back))
370            .ok_or_else(|| format!("step '{step_id}' shows nothing to draw"))
371    }
372
373    /// The interactive player's data: the steps over every drawn solid, each
374    /// step's camera from its view, its picture and its view's PMI, and the
375    /// assembly's tree with each part's thumbnail. What PLM serves per revision and the offline copy inlines.
376    pub fn instructions_player_data(&self) -> Result<PlayerData, String> {
377        let block = self.instructions_required()?;
378        let unposed = self.instructions_export_displays();
379        let mut scene = player::Scene::default();
380        // A step's motion is keyed by the assembly's own occurrences; the
381        // nodes sit under the wrapper.
382        scene.wrapper = self.instructions_wrapper().map(|(id, _)| id).unwrap_or_default();
383        let flat = |values: &[[f32; 3]]| values.iter().flatten().copied().collect::<Vec<f32>>();
384        for solid in self.scene.solids() {
385            let Some(display) = unposed.get(&solid.name).filter(|display| !display.is_sketch && !display.mesh.indices.is_empty()) else {
386                continue;
387            };
388            let color = display.color_override.unwrap_or_else(|| crate::color::solid_color_srgb(&display.name).map(|c| c as f32));
389            scene.nodes.push(PlayerNode { occurrence: solid.name.clone(), mesh: scene.meshes.len(), matrix: IDENTITY, color });
390            let edges = thumbnail::body_of(display).map(|body| body.edges).unwrap_or_default();
391            scene.meshes.push(
392                PlayerMesh::new(&solid.name, &flat(&display.mesh.positions), &flat(&display.mesh.normals), &display.mesh.indices)
393                    .with_edges(&edges),
394            );
395        }
396        // One mesh per part: a node's key is its part (the PLM id or the
397        // name) and the member inside it, from the occurrence that holds it,
398        // and its placement is the engine's pose of that occurrence.
399        let level = self.instructions_level()?;
400        let components = wizard::flatten(&level);
401        let mut keys = Vec::with_capacity(scene.nodes.len());
402        let mut placements = Vec::with_capacity(scene.nodes.len());
403        for node in &scene.nodes {
404            let holder = components
405                .iter()
406                .filter(|(_, c)| node.occurrence.strip_prefix(c.path.as_str()).is_some_and(|rest| rest.starts_with(':')))
407                .max_by_key(|(_, c)| c.path.len())
408                .map(|(_, c)| *c);
409            keys.push(holder.map(|c| format!("{}|{}", c.part_key(), &node.occurrence[c.path.len()..])).unwrap_or_else(|| node.occurrence.clone()));
410            placements.push(holder.and_then(|c| self.instructions_occurrence_placement(&c.path)));
411        }
412        scene.share_meshes(&keys, &placements);
413        // A part's thumbnail, once per part: every solid of `occurrence` (a
414        // part's, or a sub-assembly's), unposed.
415        let thumbnail_of = |scene: &mut player::Scene, part: &str, occurrence: &str| {
416            if scene.thumbnails.contains_key(part) {
417                return;
418            }
419            let mut capture = thumbnail::Capture::default();
420            for solid in self.scene.solids() {
421                let name = solid.name.as_str();
422                let inside = name == occurrence || name.strip_prefix(occurrence).is_some_and(|rest| rest.starts_with(':'));
423                if let Some(display) = unposed.get(name).filter(|display| inside && !display.is_sketch) {
424                    capture.bodies.extend(thumbnail::body_of(display));
425                }
426            }
427            if let Some(png) = thumbnail::render_png(&capture, PLAYER_THUMBNAIL_PX) {
428                scene.thumbnails.insert(part.to_string(), player::png_data_url(&png));
429            }
430        };
431        let pmi = self.pmi_state();
432        for (_, step) in block.steps() {
433            if step.arrows.is_empty() {
434                let derived = self.instructions_step_arrows(&block, &step.id);
435                if !derived.is_empty() {
436                    scene.arrows.insert(step.id.clone(), derived);
437                }
438            }
439            // The step view's explode poses, a matrix per posed node, in the
440            // order the pictures apply them.
441            if let Some(view_id) = step.view.as_deref() {
442                let report = self.pmi_report.as_ref().and_then(|report| report.view(view_id));
443                let posed: Vec<player::Pose> = scene
444                    .nodes
445                    .iter()
446                    .enumerate()
447                    .filter_map(|(node, n)| {
448                        let poses = crate::sheets::project::poses_of(report, &n.occurrence);
449                        (!poses.is_empty()).then(|| player::Pose { node, matrix: poses.iter().fold(IDENTITY, |m, pose| mul4(pose.matrix(), m)) })
450                    })
451                    .collect();
452                if !posed.is_empty() {
453                    scene.poses.insert(step.id.clone(), posed);
454                }
455            }
456            let view = step.view.as_deref().and_then(|id| pmi.views.iter().find(|view| view.id == id));
457            let camera = view.and_then(|view| view.camera.as_ref());
458            // The PMI the view shows, laid out as the 3D PDF lays it out.
459            if let (Some(view), Some(camera)) = (view, camera) {
460                if let Some(rows) = self.pmi_report.as_ref().and_then(|report| report.view(&view.id)) {
461                    let drawn = crate::sheets::pdf3d::view_annotations(view, camera, rows);
462                    let texts: Vec<String> = drawn.iter().map(|(row, _)| row.text.clone()).filter(|text| !text.trim().is_empty()).collect();
463                    let lines: Vec<Vec<[f64; 3]>> = drawn.iter().flat_map(|(_, d)| d.lines.iter().cloned()).collect();
464                    let arrows: Vec<[[f64; 3]; 3]> = drawn.iter().flat_map(|(_, d)| d.arrows.iter().copied()).collect();
465                    if let Some(drawing) = player::PlayerPmi::new(&lines, &arrows, drawn.len(), texts) {
466                        scene.pmi.insert(step.id.clone(), drawing);
467                    }
468                }
469            }
470            if let Some(camera) = camera {
471                let (fov_deg, half_height) = match camera.projection {
472                    PmiProjection::Perspective { fov_y_deg } => (Some(fov_y_deg), None),
473                    PmiProjection::Orthographic { half_height } => (None, Some(half_height)),
474                };
475                scene.cameras.insert(step.id.clone(), player::Camera { eye: camera.eye, target: camera.target, up: camera.up, fov_deg, half_height });
476                if let Ok(png) = self.instructions_step_png(&step.id, PLAYER_IMAGE_PX) {
477                    scene.step_images.insert(step.id.clone(), player::png_data_url(&png));
478                }
479            }
480            for add in &step.adds {
481                // The part's first occurrence: its solid, or a sub-assembly's.
482                if let Some(occurrence) = add.occurrences.first() {
483                    thumbnail_of(&mut scene, &add.part, occurrence);
484                }
485            }
486        }
487        // The assembly's tree, each part with its thumbnail: the page's Model
488        // and Parts tabs.
489        scene.assembly = player::PlayerComponent::rows(&level);
490        let parts: Vec<(String, String)> = scene.assembly.iter().map(|row| (row.part.clone(), row.path.clone())).collect();
491        for (part, path) in parts {
492            thumbnail_of(&mut scene, &part, &path);
493        }
494        PlayerData::build(&block, scene)
495    }
496
497    /// The engine's placement of an occurrence of the wrapper's assembly
498    /// (`ACOMP1:ACOMP3`), column-major for the player: the component
499    /// features' solved poses composed along the path, the top document's
500    /// then each sub-assembly document's, as the kernel places them. `None`
501    /// for a path the documents do not place.
502    pub fn instructions_occurrence_placement(&self, occurrence: &str) -> Option<[f64; 16]> {
503        let mut world: [f64; 16] = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
504        let mut features: Vec<serde_json::Value> = self.history.features().to_vec();
505        let mut library = self.history.parts_library().clone();
506        for id in occurrence.split(':') {
507            let feature = features
508                .iter()
509                .find(|f| super::components::is_acomp_feature_type(f["type"].as_str().unwrap_or_default()) && f["inputParams"]["id"].as_str() == Some(id))?;
510            let pose = crate::pipeline::pose_params_to_transform(&feature["inputParams"]["transform"]);
511            // Row-major product: the sub-assembly's pose applies first.
512            let mut out = [0.0; 16];
513            for row in 0..4 {
514                for col in 0..4 {
515                    out[row * 4 + col] = (0..4).map(|k| world[row * 4 + k] * pose[k * 4 + col]).sum();
516                }
517            }
518            world = out;
519            let part = feature["inputParams"]["partName"].as_str()?.to_string();
520            let document = library[part.as_str()]["document"].clone();
521            features = document["features"].as_array().cloned().unwrap_or_default();
522            library = document["partsLibrary"].clone();
523        }
524        // Column-major for the page.
525        Some(std::array::from_fn(|i| world[(i % 4) * 4 + i / 4]))
526    }
527
528    /// The offline player: one self-contained `.html` with the player data
529    /// inlined, in `mode`. Returns the page and the data it carries.
530    pub fn instructions_html(&self, mode: Mode) -> Result<(String, PlayerData), String> {
531        let data = self.instructions_player_data()?;
532        Ok((player::offline_html(&data, mode), data))
533    }
534}
535
536/// `path` is `occurrence` or a solid under it.
537fn under(path: &str, occurrence: &str) -> bool {
538    path == occurrence || path.strip_prefix(occurrence).is_some_and(|rest| rest.starts_with(':'))
539}
540
541/// A body's world bounds over the vertices its triangles use.
542fn body_bounds(body: &thumbnail::Body) -> ([f64; 3], [f64; 3]) {
543    let mut lo = [f64::INFINITY; 3];
544    let mut hi = [f64::NEG_INFINITY; 3];
545    for &i in &body.indices {
546        let p = body.positions[i as usize];
547        for k in 0..3 {
548            lo[k] = lo[k].min(p[k] as f64);
549            hi[k] = hi[k].max(p[k] as f64);
550        }
551    }
552    (lo, hi)
553}
554
555/// Column-major 4×4 product `a · b` (b applied first).
556fn mul4(a: [f64; 16], b: [f64; 16]) -> [f64; 16] {
557    let mut out = [0.0; 16];
558    for col in 0..4 {
559        for row in 0..4 {
560            out[col * 4 + row] = (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum();
561        }
562    }
563    out
564}
565
566/// `display`'s body for a step picture: posed by `poses` in order (points,
567/// and normals through the same map), grey and without edges when an earlier
568/// step `placed` it.
569fn posed_body(display: &SolidDisplay, poses: &[crate::sheets::project::SolidPose], placed: bool) -> Option<thumbnail::Body> {
570    let mut body = thumbnail::body_of(display)?;
571    if !poses.is_empty() {
572        let pose = |p: [f64; 3]| poses.iter().fold(p, |p, pose| pose.apply(p));
573        let f64s = |p: [f32; 3]| [p[0] as f64, p[1] as f64, p[2] as f64];
574        let f32s = |p: [f64; 3]| [p[0] as f32, p[1] as f32, p[2] as f32];
575        for (i, normal) in body.normals.iter_mut().enumerate() {
576            let Some(&at) = body.positions.get(i) else { break };
577            let (p, n) = (f64s(at), f64s(*normal));
578            let (from, to) = (pose(p), pose([p[0] + n[0], p[1] + n[1], p[2] + n[2]]));
579            *normal = f32s([to[0] - from[0], to[1] - from[1], to[2] - from[2]]);
580        }
581        for position in body.positions.iter_mut() {
582            *position = f32s(pose(f64s(*position)));
583        }
584        for line in body.edges.iter_mut() {
585            for point in line.iter_mut() {
586                *point = f32s(pose(f64s(*point)));
587            }
588        }
589    }
590    if placed {
591        body.colors.iter_mut().for_each(|colour| *colour = PLACED_RGB);
592        body.edges.clear();
593    }
594    Some(body)
595}
596
597/// A Line art step picture's parts and arrows in world space: each drawn
598/// solid posed by its view's explode rows, with whether the step adds it,
599/// and each arrow over the seated parts it moves ([`line_art::still_arrow`]).
600struct LineArtScene {
601    bodies: Vec<(thumbnail::Body, bool)>,
602    arrows: Vec<([f64; 3], [f64; 3])>,
603}
604
605fn line_art_scene(
606    unposed: &Unposed,
607    report: Option<&brep_kernel::PmiViewReport>,
608    solids: &[String],
609    placed: &[String],
610    arrows: &[Arrow],
611    _frame: &ViewFrame,
612) -> LineArtScene {
613    let mut bodies = Vec::new();
614    let mut bounds: Vec<(&str, ([f64; 3], [f64; 3]))> = Vec::new();
615    for name in solids {
616        let Some(display) = unposed.get(name).filter(|display| !display.is_sketch) else { continue };
617        let Some(body) = posed_body(display, &poses_of(report, name), false) else { continue };
618        let adding = !placed.contains(name);
619        if adding {
620            bounds.push((name.as_str(), body_bounds(&body)));
621        }
622        bodies.push((body, adding));
623    }
624    let mut out = Vec::new();
625    for arrow in arrows {
626        let mut lo = [f64::INFINITY; 3];
627        let mut hi = [f64::NEG_INFINITY; 3];
628        for (name, (a, b)) in &bounds {
629            if under(name, &arrow.occurrence) {
630                lo = std::array::from_fn(|k| lo[k].min(a[k]));
631                hi = std::array::from_fn(|k| hi[k].max(b[k]));
632            }
633        }
634        if lo[0] <= hi[0] {
635            out.extend(line_art::still_arrow(lo, hi, arrow.start_offset()));
636        }
637    }
638    LineArtScene { bodies, arrows: out }
639}
640
641/// The paper cell of a Line art picture's coverage grid, mm.
642const LINE_ART_CELL_MM: f64 = 0.12;
643/// How far (mm) to either side of a line its two sides are read.
644const LINE_ART_SIDE_MM: f64 = 0.3;
645
646/// Put the Line art layer on a step picture drawn at `placed`: the visible
647/// faces as flat triangles painted back to front (back faces left out), the
648/// lines that bound a part against the ground or another tier moved into
649/// the outlines, and the arrows on paper.
650fn line_art_layer(drawing: &mut ViewDrawing, art: &LineArtScene, frame: &ViewFrame, placed: &PlacedView, rect: Rect) {
651    let paper = |p: [f64; 3]| -> ([f64; 2], f64) {
652        let v = frame.to_view(p);
653        (to_paper(placed, v[0], v[1]), v[2])
654    };
655    let mut fills: Vec<(f64, [[f64; 2]; 3], bool)> = Vec::new();
656    for (body, adding) in &art.bodies {
657        let projected: Vec<([f64; 2], f64)> = body.positions.iter().map(|p| paper([p[0] as f64, p[1] as f64, p[2] as f64])).collect();
658        for tri in body.indices.chunks_exact(3) {
659            let [a, b, c] = [tri[0] as usize, tri[1] as usize, tri[2] as usize];
660            // A face turned away from the eye is behind the part's front.
661            let n: [f64; 3] = std::array::from_fn(|k| (body.normals[a][k] + body.normals[b][k] + body.normals[c][k]) as f64);
662            if n[0] * frame.forward[0] + n[1] * frame.forward[1] + n[2] * frame.forward[2] > 0.2 * (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt() {
663                continue;
664            }
665            let (pa, pb, pc) = (projected[a], projected[b], projected[c]);
666            let area = (pb.0[0] - pa.0[0]) * (pc.0[1] - pa.0[1]) - (pc.0[0] - pa.0[0]) * (pb.0[1] - pa.0[1]);
667            if area.abs() < 1e-6 {
668                continue;
669            }
670            fills.push(((pa.1 + pb.1 + pc.1) / 3.0, [pa.0, pb.0, pc.0], *adding));
671        }
672    }
673    // Furthest first.
674    fills.sort_by(|a, b| b.0.total_cmp(&a.0));
675    // Which tier shows in each cell of the picture, painted as the fills are.
676    let cols = ((rect.w / LINE_ART_CELL_MM).ceil() as usize).max(1);
677    let rows = ((rect.h / LINE_ART_CELL_MM).ceil() as usize).max(1);
678    let mut tier = vec![0u8; cols * rows];
679    for (_, t, adding) in &fills {
680        let value = if *adding { 2 } else { 1 };
681        let x0 = ((t.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min) - rect.x) / LINE_ART_CELL_MM).floor().max(0.0) as usize;
682        let x1 = (((t.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max) - rect.x) / LINE_ART_CELL_MM).ceil() as usize).min(cols);
683        let y0 = ((t.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min) - rect.y) / LINE_ART_CELL_MM).floor().max(0.0) as usize;
684        let y1 = (((t.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max) - rect.y) / LINE_ART_CELL_MM).ceil() as usize).min(rows);
685        let side = |a: [f64; 2], b: [f64; 2], p: [f64; 2]| (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
686        let orient = side(t[0], t[1], t[2]).signum();
687        for y in y0..y1 {
688            for x in x0..x1 {
689                let p = [rect.x + (x as f64 + 0.5) * LINE_ART_CELL_MM, rect.y + (y as f64 + 0.5) * LINE_ART_CELL_MM];
690                if (0..3).all(|k| side(t[k], t[(k + 1) % 3], p) * orient >= 0.0) {
691                    tier[y * cols + x] = value;
692                }
693            }
694        }
695    }
696    let tier_at = |p: [f64; 2]| -> u8 {
697        let (x, y) = ((p[0] - rect.x) / LINE_ART_CELL_MM, (p[1] - rect.y) / LINE_ART_CELL_MM);
698        if x < 0.0 || y < 0.0 || x >= cols as f64 || y >= rows as f64 {
699            return 0;
700        }
701        tier[y as usize * cols + x as usize]
702    };
703    // A segment is on its part's outline when either side shows another
704    // tier or the ground.
705    let outline_of = |own: u8| {
706        move |a: [f64; 2], b: [f64; 2]| -> bool {
707            let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
708            let length = (dx * dx + dy * dy).sqrt();
709            if !(length > 1e-9) {
710                return false;
711            }
712            let (nx, ny) = (-dy / length * LINE_ART_SIDE_MM, dx / length * LINE_ART_SIDE_MM);
713            let mid = [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5];
714            tier_at([mid[0] + nx, mid[1] + ny]) != own || tier_at([mid[0] - nx, mid[1] - ny]) != own
715        }
716    };
717    let mut outlines = Vec::new();
718    let (edges, placed_outlines) = split_runs(std::mem::take(&mut drawing.edges), outline_of(1));
719    outlines.extend(placed_outlines.into_iter().map(|run| (run, false)));
720    let (lit, adding_outlines) = split_runs(std::mem::take(&mut drawing.highlighted), outline_of(2));
721    outlines.extend(adding_outlines.into_iter().map(|run| (run, true)));
722    drawing.edges = edges;
723    drawing.highlighted = lit;
724    let arrows = art.arrows.iter().map(|(tail, tip)| (paper(*tail).0, paper(*tip).0)).collect();
725    drawing.line_art = Some(LineArtLayer {
726        ground: [rect.x, rect.y, rect.x + rect.w, rect.y + rect.h],
727        fills: fills.into_iter().map(|(_, t, adding)| (t, adding)).collect(),
728        outlines,
729        arrows,
730    });
731}
732
733/// `runs` cut where `pred` changes along them: `(false runs, true runs)`.
734fn split_runs(runs: Vec<Vec<[f64; 2]>>, pred: impl Fn([f64; 2], [f64; 2]) -> bool) -> (Vec<Vec<[f64; 2]>>, Vec<Vec<[f64; 2]>>) {
735    let (mut no, mut yes) = (Vec::new(), Vec::new());
736    for run in runs {
737        let mut current: Vec<[f64; 2]> = Vec::new();
738        let mut class: Option<bool> = None;
739        for pair in run.windows(2) {
740            let c = pred(pair[0], pair[1]);
741            if class.is_some_and(|k| k != c) {
742                let done = std::mem::take(&mut current);
743                if class == Some(true) { yes.push(done) } else { no.push(done) }
744            }
745            if current.is_empty() {
746                current.push(pair[0]);
747            }
748            current.push(pair[1]);
749            class = Some(c);
750        }
751        if current.len() >= 2 {
752            if class == Some(true) { yes.push(current) } else { no.push(current) }
753        }
754    }
755    (no, yes)
756}
757
758/// How close (paper mm) a visible segment's midpoint must lie to one of the
759/// added parts' own runs to be drawn in the highlight stroke.
760const HIGHLIGHT_TOLERANCE_MM: f64 = 0.02;
761
762/// `runs` split into the runs to draw in the model stroke and those to draw
763/// highlighted: a segment is highlighted when its midpoint lies on a segment
764/// of `added` (the same projection with only the added parts drawn, so every
765/// run of theirs that survives occlusion by the rest is found there). Each
766/// input run is cut where its class changes.
767pub(super) fn split_highlighted(runs: Vec<Vec<[f64; 2]>>, added: &[Vec<[f64; 2]>]) -> (Vec<Vec<[f64; 2]>>, Vec<Vec<[f64; 2]>>) {
768    let segments: Vec<([f64; 2], [f64; 2])> = added.iter().flat_map(|run| run.windows(2).map(|w| (w[0], w[1]))).collect();
769    let on_added = |p: [f64; 2]| segments.iter().any(|(a, b)| point_segment_distance(p, *a, *b) <= HIGHLIGHT_TOLERANCE_MM);
770    let (mut plain, mut lit) = (Vec::new(), Vec::new());
771    for run in runs {
772        let mut current: Vec<[f64; 2]> = Vec::new();
773        let mut class: Option<bool> = None;
774        for pair in run.windows(2) {
775            let mid = [(pair[0][0] + pair[1][0]) * 0.5, (pair[0][1] + pair[1][1]) * 0.5];
776            let highlighted = on_added(mid);
777            if class.is_some_and(|c| c != highlighted) {
778                let done = std::mem::take(&mut current);
779                if class == Some(true) { lit.push(done) } else { plain.push(done) }
780            }
781            if current.is_empty() {
782                current.push(pair[0]);
783            }
784            current.push(pair[1]);
785            class = Some(highlighted);
786        }
787        if current.len() >= 2 {
788            if class == Some(true) { lit.push(current) } else { plain.push(current) }
789        }
790    }
791    (plain, lit)
792}
793
794fn point_segment_distance(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
795    let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
796    let len2 = dx * dx + dy * dy;
797    let t = if len2 <= 0.0 { 0.0 } else { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) };
798    ((p[0] - a[0] - t * dx).powi(2) + (p[1] - a[1] - t * dy).powi(2)).sqrt()
799}