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