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, Deferred, LinesMode, Model, Unposed, ViewDrawing, ViewFrame};
27use crate::sheets::PlacedView;
28use crate::thumbnail;
29use brep_instructions::edit;
30use brep_instructions::layout::{self, Rect};
31use brep_instructions::player::{self, Mode, PlayerData, PlayerMesh, PlayerNode};
32use brep_kernel::PmiProjection;
33use brep_kernel::PmiView;
34use serde_json::json;
35use std::collections::HashMap;
36
37/// A per-step PNG's edge, in pixels.
38pub const IMAGE_PX: u32 = 800;
39
40/// The colour a PNG draws a part an earlier step placed in (sRGB 0..1).
41const PLACED_RGB: [f32; 3] = [0.80, 0.81, 0.83];
42
43/// A step picture's edge in the player data, in pixels: the picture the
44/// player shows without WebGL.
45const PLAYER_IMAGE_PX: u32 = 480;
46
47/// A part thumbnail's edge in the player data, in pixels.
48const PLAYER_THUMBNAIL_PX: u32 = 96;
49
50const 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];
51
52/// How long an export waits for the runner's topology reply before it draws
53/// what it has ([`EngineState::instructions_topology_ready`]).
54pub const TOPOLOGY_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
55
56/// A PDF prepared over successive frames. Heavy exact passes run on the history runner.
57/// Dropping this job cancels delivery; the document stamp prevents mixed-revision output.
58pub struct InstructionsPdfExport {
59    block: brep_instructions::model::Instructions,
60    layout: Vec<layout::Page>,
61    todo: Vec<(PictureAt, String, Rect)>,
62    pictures: HashMap<PictureAt, ViewDrawing>,
63    next: usize,
64    stamp: (u64, u64),
65    started: f64,
66    unposed: Option<Unposed>,
67    views: Vec<PmiView>,
68}
69
70impl InstructionsPdfExport {
71    pub fn progress(&self) -> (usize, usize) { (self.next, self.todo.len()) }
72}
73
74impl EngineState {
75    /// Begin without waiting for topology or projecting any picture.
76    pub fn begin_instructions_pdf(&mut self) -> Result<InstructionsPdfExport, String> {
77        if self.run_pending() { return Err("wait for the model to finish rebuilding before exporting".into()); }
78        let block = self.instructions_required()?;
79        let layout = layout::pages(&block)?;
80        let todo = print::picture_rects(&block, &layout);
81        let names: Vec<String> = self.scene.solids().iter().filter(|solid| !solid.is_sketch).map(|solid| solid.name.clone()).collect();
82        self.request_exact_solids(names.iter().map(String::as_str));
83        Ok(InstructionsPdfExport {
84            block, layout, todo, pictures: HashMap::new(), next: 0,
85            stamp: (self.applied_generation(), self.history_revision()),
86            started: now_ms(), unposed: None, views: self.pmi_state().views,
87        })
88    }
89
90    /// Poll at most one picture per frame. Never wait for the runner or fall back
91    /// to computing hidden lines on the UI thread if the runner refuses a job.
92    pub fn poll_instructions_pdf(&mut self, job: &mut InstructionsPdfExport) -> Result<Option<(Vec<u8>, usize)>, String> {
93        if job.stamp != (self.applied_generation(), self.history_revision()) {
94            return Err("the document changed during PDF export; export again".into());
95        }
96        self.pump_topology();
97        self.pump_sheet_lines();
98        if self.topology_pending() {
99            if now_ms() - job.started >= TOPOLOGY_WAIT.as_secs_f64() * 1000.0 { return Err("timed out loading exact geometry for PDF export".into()); }
100            return Ok(None);
101        }
102        if self.sheet_lines_pending() { return Ok(None); }
103        if job.unposed.is_none() { job.unposed = Some(self.instructions_export_displays()); }
104        if let Some((at, step_id, rect)) = job.todo.get(job.next) {
105            if let Some(view) = job.block.step(step_id).and_then(|step| step.view.as_deref()) {
106                let placed = self.instructions_placed_names(&job.block, step_id);
107                let pending = std::cell::Cell::new(false);
108                // Discover at most one cache miss, then submit it after the scene borrow ends.
109                let mut requests = Vec::new();
110                let mut defer = |request| { requests.push(request); pending.set(true); Deferred::Pending };
111                let drawing = self.instructions_project_with(job.unposed.as_ref().unwrap(), &job.views, view, *rect, &placed,
112                    &mut LinesMode::Export(&mut defer), &pending);
113                for request in requests {
114                    if matches!(self.sheet_lines.defer(self.runner.as_mut(), request), Deferred::Here) {
115                        return Err("the background runner could not draw the PDF picture; export again".into());
116                    }
117                }
118                let Some(drawing) = drawing else { return Ok(None) };
119                if !drawing.error.is_empty() { return Err(drawing.error); }
120                job.pictures.insert(*at, drawing);
121            }
122            job.next += 1;
123            return Ok(None);
124        }
125        let pages = print::pages(&job.block, &job.layout, &job.pictures);
126        let refs = pages.iter().collect::<Vec<_>>();
127        Ok(Some((crate::sheets::pdf::write_pdf(&print::title(&job.block), &refs), pages.len())))
128    }
129
130    /// Ask the runner for the B-rep of every solid the step pictures draw
131    /// (see [`Self::request_exact_solids`]) and wait for the reply, up to
132    /// [`TOPOLOGY_WAIT`], on a runner that can be waited on (the Inline runner
133    /// answers before the request returns; the thread runner is waited on; a
134    /// worker cannot be, and the caller holds the export until
135    /// [`Self::topology_pending`] clears). Returns whether every drawn solid's
136    /// B-rep is in the scene now: the exact pass draws the pictures. Otherwise
137    /// the mesh approximation does, and the file says so.
138    /// Ask for the exact solids the step pictures draw WITHOUT waiting:
139    /// returns whether they are all here already. The frame's `pump` lands the
140    /// replies; a caller that may not block (automation) checks
141    /// [`Self::topology_pending`] on later frames.
142    pub fn instructions_request_topology(&mut self) -> bool {
143        let names: Vec<String> = self.scene.solids().iter().filter(|solid| !solid.is_sketch).map(|solid| solid.name.clone()).collect();
144        self.request_exact_solids(names.iter().map(String::as_str));
145        self.pump_topology();
146        !self.topology_pending()
147    }
148
149    pub fn instructions_topology_ready(&mut self) -> bool {
150        let names: Vec<String> = self.scene.solids().iter().filter(|solid| !solid.is_sketch).map(|solid| solid.name.clone()).collect();
151        self.request_exact_solids(names.iter().map(String::as_str));
152        let deadline = now_ms() + TOPOLOGY_WAIT.as_secs_f64() * 1000.0;
153        while self.topology_pending() {
154            let now = now_ms();
155            if now >= deadline || !self.runner.wait_topology(std::time::Duration::from_secs_f64((deadline - now) / 1000.0)) {
156                break;
157            }
158            self.pump_topology();
159        }
160        !self.topology_pending()
161    }
162
163    /// Every display as the files draw it: unposed (the explode originals of
164    /// the view the viewport shows) and shown, so only each step view's own
165    /// hidden list and poses decide what a picture holds.
166    fn instructions_export_displays(&self) -> Unposed {
167        self.scene
168            .solids()
169            .iter()
170            .map(|solid| {
171                let mut display = self.pmi_explode_originals.get(&solid.name).unwrap_or(solid).clone();
172                display.visible = true;
173                (solid.name.clone(), display)
174            })
175            .collect()
176    }
177
178    /// The PMI view `view_id` projected into `rect`: projected once at scale
179    /// 1 to measure it, then placed at the scale and position that fit it.
180    /// The parts `placed` by earlier steps are drawn in the model stroke and
181    /// the rest, the parts the step adds, in the highlight stroke
182    /// ([`ViewDrawing::highlighted`]). Visibility is decided once over every
183    /// part; a second pass at the same placement with the placed parts hidden
184    /// says which visible runs lie on the added parts' own edges.
185    fn instructions_project(&self, unposed: &Unposed, views: &[PmiView], view_id: &str, rect: Rect, placed: &[String]) -> ViewDrawing {
186        self.instructions_project_with(unposed, views, view_id, rect, placed, &mut LinesMode::Compute, &std::cell::Cell::new(false)).expect("synchronous projection")
187    }
188
189    fn instructions_project_with(&self, unposed: &Unposed, views: &[PmiView], view_id: &str, rect: Rect, placed: &[String], mode: &mut LinesMode, pending: &std::cell::Cell<bool>) -> Option<ViewDrawing> {
190        let env = self.sheet_env();
191        let model = Model { scene: &self.scene, unposed, env: &env };
192        let view = views.iter().find(|view| view.id == view_id);
193        let report = self.pmi_report.as_ref().and_then(|report| report.view(view_id));
194        let placement = |position: [f64; 2], scale: f64| -> PlacedView {
195            serde_json::from_value(json!({ "id": format!("IV-{view_id}"), "view": view_id, "position": position, "scale": scale }))
196                .expect("a placement from its id, view, position and scale")
197        };
198        let measured = project_view_through(&model, &placement([0.0, 0.0], 1.0), view, report, None, None, mode);
199        if pending.get() { return None; }
200        if !measured.error.is_empty() {
201            return Some(measured);
202        }
203        let (position, scale) = print::fit(measured.bounds, rect);
204        let mut drawing = project_view_through(&model, &placement(position, scale), view, report, None, None, mode);
205        if pending.get() { return None; }
206        let Some(view) = view else { return Some(drawing) };
207        if placed.is_empty() || drawing.edges.is_empty() {
208            // Nothing placed yet: everything drawn is being added.
209            drawing.highlighted = std::mem::take(&mut drawing.edges);
210            return Some(drawing);
211        }
212        // The same view with what earlier steps placed hidden too (a
213        // transient hide step on a copy, never stored).
214        let mut added_only = view.clone();
215        let extra: Vec<String> = placed.iter().filter(|name| !added_only.hides(name)).cloned().collect();
216        added_only.annotations.push(brep_kernel::PmiAnnotation::hide_step("HID-placed", &extra, true));
217        let alone = project_view_through(&model, &placement(position, scale), Some(&added_only), report, None, None, mode);
218        if pending.get() { return None; }
219        if !alone.error.is_empty() { return Some(alone); }
220        let (edges, highlighted) = split_highlighted(std::mem::take(&mut drawing.edges), &alone.edges);
221        drawing.edges = edges;
222        drawing.highlighted = highlighted;
223        Some(drawing)
224    }
225
226    /// The parts earlier steps placed, by solid name, for `step_id`'s picture.
227    fn instructions_placed_names(&self, block: &brep_instructions::model::Instructions, step_id: &str) -> Vec<String> {
228        let names: Vec<String> = self.scene.solids().iter().map(|solid| solid.name.clone()).collect();
229        edit::presentation(block, step_id, &names).map(|presentation| presentation.placed).unwrap_or_default()
230    }
231
232    /// The instructions as one PDF: the cover, the parts list, the tools list
233    /// and the steps on the document's page template, the pictures drawn by
234    /// the exact pass ([`Self::instructions_topology_ready`]). Returns the
235    /// bytes and the page count.
236    pub fn instructions_pdf(&mut self) -> Result<(Vec<u8>, usize), String> {
237        let block = self.instructions_required()?;
238        self.instructions_topology_ready();
239        let layout = layout::pages(&block)?;
240        let pmi = self.pmi_state();
241        let unposed = self.instructions_export_displays();
242        let mut pictures: HashMap<PictureAt, ViewDrawing> = HashMap::new();
243        for (at, step_id, rect) in print::picture_rects(&block, &layout) {
244            if let Some(view) = block.step(&step_id).and_then(|step| step.view.clone()) {
245                let placed = self.instructions_placed_names(&block, &step_id);
246                pictures.insert(at, self.instructions_project(&unposed, &pmi.views, &view, rect, &placed));
247            }
248        }
249        let pages = print::pages(&block, &layout, &pictures);
250        let refs: Vec<&crate::sheets::project::SheetDrawing> = pages.iter().collect();
251        Ok((crate::sheets::pdf::write_pdf(&print::title(&block), &refs), pages.len()))
252    }
253
254    /// One step's picture as SVG: its number and title over its view, hidden
255    /// lines removed by the exact pass ([`Self::instructions_topology_ready`]).
256    pub fn instructions_step_svg(&mut self, step_id: &str) -> Result<String, String> {
257        let block = self.instructions_required()?;
258        let step = block.step(step_id).ok_or_else(|| format!("no step '{step_id}'"))?;
259        self.instructions_topology_ready();
260        let placed = self.instructions_placed_names(&block, step_id);
261        let picture = step.view.as_deref().map(|view| {
262            self.instructions_project(&self.instructions_export_displays(), &self.pmi_state().views, view, print::image_picture_rect(), &placed)
263        });
264        Ok(crate::sheets::svg::to_svg(&print::step_image(&block, step_id, picture)?))
265    }
266
267    /// One step's picture as a `size`-pixel square PNG from its view's camera:
268    /// what the step adds in its colours with its edges, what earlier steps
269    /// placed in grey, what comes later left out, each part where the view's
270    /// explode rows put it.
271    pub fn instructions_step_png(&self, step_id: &str, size: u32) -> Result<Vec<u8>, String> {
272        let block = self.instructions_required()?;
273        let step = block.step(step_id).ok_or_else(|| format!("no step '{step_id}'"))?;
274        let view_id = step.view.as_deref().ok_or_else(|| format!("step '{step_id}' has no view: run the wizard or pick one"))?;
275        let pmi = self.pmi_state();
276        let view = pmi.views.iter().find(|view| view.id == view_id).ok_or_else(|| format!("no PMI view '{view_id}'"))?;
277        let camera = view.camera.as_ref().ok_or_else(|| format!("PMI view '{view_id}' has no captured camera"))?;
278        let frame = ViewFrame::of(camera);
279        let back = frame.forward.map(|c| -c);
280        let report = self.pmi_report.as_ref().and_then(|report| report.view(view_id));
281        let names: Vec<String> = self.scene.solids().iter().map(|solid| solid.name.clone()).collect();
282        let presentation = edit::presentation(&block, step_id, &names)?;
283        let unposed = self.instructions_export_displays();
284        let mut capture = thumbnail::Capture::default();
285        for name in &names {
286            if view.hides(name) || presentation.hidden.contains(name) {
287                continue;
288            }
289            let Some(display) = unposed.get(name).filter(|display| !display.is_sketch) else { continue };
290            let Some(body) = posed_body(display, &poses_of(report, name), presentation.placed.contains(name)) else { continue };
291            capture.bodies.push(body);
292        }
293        thumbnail::render_png_from(&capture, size, (frame.right, frame.up, back))
294            .ok_or_else(|| format!("step '{step_id}' shows nothing to draw"))
295    }
296
297    /// The interactive player's data: the steps over every drawn solid, each
298    /// step's camera from its view, its picture and its view's PMI, and the
299    /// assembly's tree with each part's thumbnail. What PLM serves per revision and the offline copy inlines.
300    pub fn instructions_player_data(&self) -> Result<PlayerData, String> {
301        let block = self.instructions_required()?;
302        let unposed = self.instructions_export_displays();
303        let mut scene = player::Scene::default();
304        // A step's motion is keyed by the assembly's own occurrences; the
305        // nodes sit under the wrapper.
306        scene.wrapper = self.instructions_wrapper().map(|(id, _)| id).unwrap_or_default();
307        let flat = |values: &[[f32; 3]]| values.iter().flatten().copied().collect::<Vec<f32>>();
308        for solid in self.scene.solids() {
309            let Some(display) = unposed.get(&solid.name).filter(|display| !display.is_sketch && !display.mesh.indices.is_empty()) else {
310                continue;
311            };
312            let color = display.color_override.unwrap_or_else(|| crate::color::solid_color_srgb(&display.name).map(|c| c as f32));
313            scene.nodes.push(PlayerNode { occurrence: solid.name.clone(), mesh: scene.meshes.len(), matrix: IDENTITY, color });
314            let edges = thumbnail::body_of(display).map(|body| body.edges).unwrap_or_default();
315            scene.meshes.push(
316                PlayerMesh::new(&solid.name, &flat(&display.mesh.positions), &flat(&display.mesh.normals), &display.mesh.indices)
317                    .with_edges(&edges),
318            );
319        }
320        // One mesh per part: a node's key is its part (the PLM id or the
321        // name) and the member inside it, from the occurrence that holds it,
322        // and its placement is the engine's pose of that occurrence.
323        let level = self.instructions_level()?;
324        let components = wizard::flatten(&level);
325        let mut keys = Vec::with_capacity(scene.nodes.len());
326        let mut placements = Vec::with_capacity(scene.nodes.len());
327        for node in &scene.nodes {
328            let holder = components
329                .iter()
330                .filter(|(_, c)| node.occurrence.strip_prefix(c.path.as_str()).is_some_and(|rest| rest.starts_with(':')))
331                .max_by_key(|(_, c)| c.path.len())
332                .map(|(_, c)| *c);
333            keys.push(holder.map(|c| format!("{}|{}", c.part_key(), &node.occurrence[c.path.len()..])).unwrap_or_else(|| node.occurrence.clone()));
334            placements.push(holder.and_then(|c| self.instructions_occurrence_placement(&c.path)));
335        }
336        scene.share_meshes(&keys, &placements);
337        // A part's thumbnail, once per part: every solid of `occurrence` (a
338        // part's, or a sub-assembly's), unposed.
339        let thumbnail_of = |scene: &mut player::Scene, part: &str, occurrence: &str| {
340            if scene.thumbnails.contains_key(part) {
341                return;
342            }
343            let mut capture = thumbnail::Capture::default();
344            for solid in self.scene.solids() {
345                let name = solid.name.as_str();
346                let inside = name == occurrence || name.strip_prefix(occurrence).is_some_and(|rest| rest.starts_with(':'));
347                if let Some(display) = unposed.get(name).filter(|display| inside && !display.is_sketch) {
348                    capture.bodies.extend(thumbnail::body_of(display));
349                }
350            }
351            if let Some(png) = thumbnail::render_png(&capture, PLAYER_THUMBNAIL_PX) {
352                scene.thumbnails.insert(part.to_string(), player::png_data_url(&png));
353            }
354        };
355        let pmi = self.pmi_state();
356        for (_, step) in block.steps() {
357            // The step view's explode poses, a matrix per posed node, in the
358            // order the pictures apply them.
359            if let Some(view_id) = step.view.as_deref() {
360                let report = self.pmi_report.as_ref().and_then(|report| report.view(view_id));
361                let posed: Vec<player::Pose> = scene
362                    .nodes
363                    .iter()
364                    .enumerate()
365                    .filter_map(|(node, n)| {
366                        let poses = crate::sheets::project::poses_of(report, &n.occurrence);
367                        (!poses.is_empty()).then(|| player::Pose { node, matrix: poses.iter().fold(IDENTITY, |m, pose| mul4(pose.matrix(), m)) })
368                    })
369                    .collect();
370                if !posed.is_empty() {
371                    scene.poses.insert(step.id.clone(), posed);
372                }
373            }
374            let view = step.view.as_deref().and_then(|id| pmi.views.iter().find(|view| view.id == id));
375            let camera = view.and_then(|view| view.camera.as_ref());
376            // The PMI the view shows, laid out as the 3D PDF lays it out.
377            if let (Some(view), Some(camera)) = (view, camera) {
378                if let Some(rows) = self.pmi_report.as_ref().and_then(|report| report.view(&view.id)) {
379                    let drawn = crate::sheets::pdf3d::view_annotations(view, camera, rows);
380                    let texts: Vec<String> = drawn.iter().map(|(row, _)| row.text.clone()).filter(|text| !text.trim().is_empty()).collect();
381                    let lines: Vec<Vec<[f64; 3]>> = drawn.iter().flat_map(|(_, d)| d.lines.iter().cloned()).collect();
382                    let arrows: Vec<[[f64; 3]; 3]> = drawn.iter().flat_map(|(_, d)| d.arrows.iter().copied()).collect();
383                    if let Some(drawing) = player::PlayerPmi::new(&lines, &arrows, drawn.len(), texts) {
384                        scene.pmi.insert(step.id.clone(), drawing);
385                    }
386                }
387            }
388            if let Some(camera) = camera {
389                let (fov_deg, half_height) = match camera.projection {
390                    PmiProjection::Perspective { fov_y_deg } => (Some(fov_y_deg), None),
391                    PmiProjection::Orthographic { half_height } => (None, Some(half_height)),
392                };
393                scene.cameras.insert(step.id.clone(), player::Camera { eye: camera.eye, target: camera.target, up: camera.up, fov_deg, half_height });
394                if let Ok(png) = self.instructions_step_png(&step.id, PLAYER_IMAGE_PX) {
395                    scene.step_images.insert(step.id.clone(), player::png_data_url(&png));
396                }
397            }
398            for add in &step.adds {
399                // The part's first occurrence: its solid, or a sub-assembly's.
400                if let Some(occurrence) = add.occurrences.first() {
401                    thumbnail_of(&mut scene, &add.part, occurrence);
402                }
403            }
404        }
405        // The assembly's tree, each part with its thumbnail: the page's Model
406        // and Parts tabs.
407        scene.assembly = player::PlayerComponent::rows(&level);
408        let parts: Vec<(String, String)> = scene.assembly.iter().map(|row| (row.part.clone(), row.path.clone())).collect();
409        for (part, path) in parts {
410            thumbnail_of(&mut scene, &part, &path);
411        }
412        PlayerData::build(&block, scene)
413    }
414
415    /// The engine's placement of an occurrence of the wrapper's assembly
416    /// (`ACOMP1:ACOMP3`), column-major for the player: the component
417    /// features' solved poses composed along the path, the top document's
418    /// then each sub-assembly document's, as the kernel places them. `None`
419    /// for a path the documents do not place.
420    pub fn instructions_occurrence_placement(&self, occurrence: &str) -> Option<[f64; 16]> {
421        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];
422        let mut features: Vec<serde_json::Value> = self.history.features().to_vec();
423        let mut library = self.history.parts_library().clone();
424        for id in occurrence.split(':') {
425            let feature = features
426                .iter()
427                .find(|f| super::components::is_acomp_feature_type(f["type"].as_str().unwrap_or_default()) && f["inputParams"]["id"].as_str() == Some(id))?;
428            let pose = crate::pipeline::pose_params_to_transform(&feature["inputParams"]["transform"]);
429            // Row-major product: the sub-assembly's pose applies first.
430            let mut out = [0.0; 16];
431            for row in 0..4 {
432                for col in 0..4 {
433                    out[row * 4 + col] = (0..4).map(|k| world[row * 4 + k] * pose[k * 4 + col]).sum();
434                }
435            }
436            world = out;
437            let part = feature["inputParams"]["partName"].as_str()?.to_string();
438            let document = library[part.as_str()]["document"].clone();
439            features = document["features"].as_array().cloned().unwrap_or_default();
440            library = document["partsLibrary"].clone();
441        }
442        // Column-major for the page.
443        Some(std::array::from_fn(|i| world[(i % 4) * 4 + i / 4]))
444    }
445
446    /// The offline player: one self-contained `.html` with the player data
447    /// inlined, in `mode`. Returns the page and the data it carries.
448    pub fn instructions_html(&self, mode: Mode) -> Result<(String, PlayerData), String> {
449        let data = self.instructions_player_data()?;
450        Ok((player::offline_html(&data, mode), data))
451    }
452}
453
454/// Column-major 4×4 product `a · b` (b applied first).
455fn mul4(a: [f64; 16], b: [f64; 16]) -> [f64; 16] {
456    let mut out = [0.0; 16];
457    for col in 0..4 {
458        for row in 0..4 {
459            out[col * 4 + row] = (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum();
460        }
461    }
462    out
463}
464
465/// `display`'s body for a step picture: posed by `poses` in order (points,
466/// and normals through the same map), grey and without edges when an earlier
467/// step `placed` it.
468fn posed_body(display: &SolidDisplay, poses: &[crate::sheets::project::SolidPose], placed: bool) -> Option<thumbnail::Body> {
469    let mut body = thumbnail::body_of(display)?;
470    if !poses.is_empty() {
471        let pose = |p: [f64; 3]| poses.iter().fold(p, |p, pose| pose.apply(p));
472        let f64s = |p: [f32; 3]| [p[0] as f64, p[1] as f64, p[2] as f64];
473        let f32s = |p: [f64; 3]| [p[0] as f32, p[1] as f32, p[2] as f32];
474        for (i, normal) in body.normals.iter_mut().enumerate() {
475            let Some(&at) = body.positions.get(i) else { break };
476            let (p, n) = (f64s(at), f64s(*normal));
477            let (from, to) = (pose(p), pose([p[0] + n[0], p[1] + n[1], p[2] + n[2]]));
478            *normal = f32s([to[0] - from[0], to[1] - from[1], to[2] - from[2]]);
479        }
480        for position in body.positions.iter_mut() {
481            *position = f32s(pose(f64s(*position)));
482        }
483        for line in body.edges.iter_mut() {
484            for point in line.iter_mut() {
485                *point = f32s(pose(f64s(*point)));
486            }
487        }
488    }
489    if placed {
490        body.colors.iter_mut().for_each(|colour| *colour = PLACED_RGB);
491        body.edges.clear();
492    }
493    Some(body)
494}
495
496/// How close (paper mm) a visible segment's midpoint must lie to one of the
497/// added parts' own runs to be drawn in the highlight stroke.
498const HIGHLIGHT_TOLERANCE_MM: f64 = 0.02;
499
500/// `runs` split into the runs to draw in the model stroke and those to draw
501/// highlighted: a segment is highlighted when its midpoint lies on a segment
502/// of `added` (the same projection with only the added parts drawn, so every
503/// run of theirs that survives occlusion by the rest is found there). Each
504/// input run is cut where its class changes.
505pub(super) fn split_highlighted(runs: Vec<Vec<[f64; 2]>>, added: &[Vec<[f64; 2]>]) -> (Vec<Vec<[f64; 2]>>, Vec<Vec<[f64; 2]>>) {
506    let segments: Vec<([f64; 2], [f64; 2])> = added.iter().flat_map(|run| run.windows(2).map(|w| (w[0], w[1]))).collect();
507    let on_added = |p: [f64; 2]| segments.iter().any(|(a, b)| point_segment_distance(p, *a, *b) <= HIGHLIGHT_TOLERANCE_MM);
508    let (mut plain, mut lit) = (Vec::new(), Vec::new());
509    for run in runs {
510        let mut current: Vec<[f64; 2]> = Vec::new();
511        let mut class: Option<bool> = None;
512        for pair in run.windows(2) {
513            let mid = [(pair[0][0] + pair[1][0]) * 0.5, (pair[0][1] + pair[1][1]) * 0.5];
514            let highlighted = on_added(mid);
515            if class.is_some_and(|c| c != highlighted) {
516                let done = std::mem::take(&mut current);
517                if class == Some(true) { lit.push(done) } else { plain.push(done) }
518            }
519            if current.is_empty() {
520                current.push(pair[0]);
521            }
522            current.push(pair[1]);
523            class = Some(highlighted);
524        }
525        if current.len() >= 2 {
526            if class == Some(true) { lit.push(current) } else { plain.push(current) }
527        }
528    }
529    (plain, lit)
530}
531
532fn point_segment_distance(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
533    let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
534    let len2 = dx * dx + dy * dy;
535    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) };
536    ((p[0] - a[0] - t * dx).powi(2) + (p[1] - a[1] - t * dy).powi(2)).sqrt()
537}