Skip to main content

brep_render/sheets/
project.rs

1//! Projecting a placed PMI view onto its sheet: the model as edge LINES with
2//! hidden lines removed, plus EVERY annotation of the saved view, all in
3//! paper millimetres.
4//!
5//! # Annotations
6//!
7//! An annotation is laid out through the same [`pmi_present`] the 3D overlay
8//! and the STEP presentation use — in its own annotation plane when it has
9//! one — and the result is projected through the view camera exactly as the
10//! overlay draws it. There is no plane filter: a plane oblique to the view
11//! draws foreshortened, a plane EDGE-ON to it draws as a line and the row
12//! says so ([`AnnotationDrawing::note`]). Circles and arcs in the plane are
13//! polylines before they are projected, so they come out as the ellipses any
14//! parallel projection gives. Annotations are NOT hidden-line tested: they
15//! are drawn on top of the model, which is what the viewport overlay does and
16//! what a drawing wants — a dimension behind the part is still a dimension.
17//!
18//! # The model lines
19//!
20//! Drawn by the ANALYTIC hidden-line pass, [`super::hlr`]: candidate curves
21//! from the B-rep (edges and the silhouettes of curved faces), split where
22//! they cross in the projection plane, each piece shown or hidden by an exact
23//! depth comparison against the faces in front of it. A section cuts the
24//! B-rep with its plane exactly ([`super::hlr::section_curves`]) and a detail
25//! decides visibility inside its circle's cylinder at its own scale. The
26//! result is cached per placement by what it depends on — the geometry, the
27//! camera, the scale and the clip — and never by where the placement sits on
28//! the paper, so a drag does not re-run it.
29//!
30//! # Before the B-rep arrives
31//!
32//! The exact pass needs every drawn solid's B-rep, which the scene PULLS from
33//! the runner when a sheet first shows it. Until it lands — and for a handle
34//! the registry could not clone — the placement is drawn by the same pass on
35//! the DISPLAY instead ([`super::hlr::visible_mesh_lines`]): the display edges
36//! and the mesh's silhouettes, split and decided against the display
37//! triangles. That is a stated approximation, never a silent one:
38//! [`ViewDrawing::hidden_line`] says `mesh`, and the PDF and SVG writers carry
39//! it into the files they write.
40
41use crate::scene::RenderScene;
42use brep_kernel::{
43    pmi_present, PmiAnnotationReport, PmiCamera, PmiLayoutStyle, PmiProjection, PmiStatus,
44    PmiView, PmiViewReport,
45};
46
47use super::{PlacedView, ORTHOGRAPHIC};
48
49/// Dihedral threshold for a drawn edge, degrees — `EdgesGeometry(geo, 30)`,
50/// the value `BREP_mcp_core::render_mesh` matches three.js on. The analytic
51/// pass reads the same bar off the exact face normals along a B-rep edge.
52pub const EDGE_ANGLE_DEG: f64 = 30.0;
53
54/// Points per millimetre: a PMI view's text size is in POINTS, and a sheet is
55/// in millimetres.
56pub const MM_PER_POINT: f64 = 25.4 / 72.0;
57
58/// The arrowhead length as a multiple of the text cap height — the viewport
59/// overlay's own proportion (12 px arrow against an 11 px cap at 12 pt), so an
60/// annotation on paper has the arrowheads it has on screen.
61pub(super) const ARROW_PER_TEXT: f64 = 12.0 / 11.0;
62
63/// The PMI stroke font's monospace advance as a multiple of the cap height
64/// (the kernel's `font::ADVANCE`, mirrored because the kernel does not publish
65/// its font metrics). The sheet renders the text with a real proportional
66/// face, which is narrower, so a box measured with this is slightly generous —
67/// which is the right way round for a clearance.
68pub(super) const TEXT_ADVANCE: f64 = 0.75;
69
70/// Clear space kept around a text run before a line is broken for it, as a
71/// multiple of the cap height.
72const TEXT_PAD: f64 = 0.3;
73
74/// A section's HATCH spacing, PAPER millimetres. A hatch is a drawing
75/// convention rather than a property of the model, so it is a paper distance
76/// at every sheet scale — a 2:1 section is hatched at the same pitch as a 1:1
77/// one, which is what makes two sections of a drawing read as one drawing.
78pub const HATCH_SPACING_MM: f64 = 2.5;
79
80/// The most hatch lines one cut face may draw. A section of a part a metre
81/// across at 1:1 would otherwise ask for four hundred thousand of them; past
82/// this the hatch is THINNED (the spacing doubles until it fits) rather than
83/// refused, because a thinned hatch still says "this is cut material" and an
84/// absent one says nothing.
85const MAX_HATCH_LINES: usize = 2000;
86
87/// When an annotation plane is EDGE-ON to the view: the cosine of the angle
88/// between the plane's normal and the viewing direction. Below this the
89/// plane projects to a line, and so does everything laid out in it. The bar
90/// is the kernel's own (`annotation_plane` accepts a plane against its
91/// geometry at `sin < 1e-3`), read on the other axis.
92pub const EDGE_ON_COS: f64 = 1e-3;
93
94// ---------------------------------------------------------------------------
95// Output
96// ---------------------------------------------------------------------------
97
98/// A whole sheet, projected — the sheet viewport draws this and the SVG and PDF
99/// writers serialise it.
100#[derive(Debug, Clone, PartialEq, serde::Serialize)]
101pub struct SheetDrawing {
102    pub id: String,
103    pub name: String,
104    #[serde(rename = "widthMm")]
105    pub width_mm: f64,
106    #[serde(rename = "heightMm")]
107    pub height_mm: f64,
108    pub views: Vec<ViewDrawing>,
109    /// The sheet's OWN dimensions — authored on the paper, anchored to the
110    /// placements' projected geometry rather than to a saved PMI view. They
111    /// live beside the views rather than inside one because a dimension is the
112    /// SHEET's (see [`super::dimension`]).
113    pub dimensions: Vec<super::dimension::DimensionDrawing>,
114    /// The sheet's ORDINATE SETS, laid out ([`super::ordinate`]). Beside the
115    /// dimensions for the same reason they are beside them in the block: a set
116    /// is its own object, not a dimension of a fifth kind.
117    pub ordinates: Vec<super::ordinate::OrdinateDrawing>,
118    /// The paper's own furniture — the border and the title block — in the same
119    /// millimetres and the same lines-and-text as the views. `None` on a sheet
120    /// that draws neither. It is built by the ENGINE rather than here
121    /// ([`crate::sheets::frame::frame`]): a title block carries the document's
122    /// name and the date, which a projection knows nothing about.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub frame: Option<super::frame::FrameDrawing>,
125}
126
127/// One placed view, projected. Paper millimetres throughout, measured from the
128/// paper's TOP-LEFT corner with y running DOWN — SVG's own frame, so the
129/// writer transforms nothing.
130#[derive(Debug, Clone, PartialEq, serde::Serialize)]
131pub struct ViewDrawing {
132    /// The placed view's id (`SV2`).
133    pub id: String,
134    /// The saved PMI view it places (`VIEW1`).
135    pub view: String,
136    /// The saved view's name, for the panel and the SVG group label.
137    pub name: String,
138    pub position: [f64; 2],
139    pub scale: f64,
140    /// Empty when the view projected; the reason it did not otherwise (no such
141    /// saved view, no captured camera, a perspective camera).
142    #[serde(skip_serializing_if = "String::is_empty")]
143    pub error: String,
144    /// The model as VISIBLE edge lines, one polyline per visible run.
145    pub edges: Vec<Vec<[f64; 2]>>,
146    /// Every enabled, resolved annotation of the saved view, projected.
147    pub annotations: Vec<AnnotationDrawing>,
148    /// Every point of this projection a SHEET dimension can be anchored to —
149    /// each named vertex, each named edge's two ends and its midpoint, and the
150    /// centre of each circular edge. The anchor space is the MODEL's topology,
151    /// so the hidden-line pass has no vote on it: a candidate whose ink the
152    /// pass removed is still here, and a dimension anchored to it still draws.
153    pub anchors: Vec<AnchorPoint>,
154    /// The saved view's text size in PAPER millimetres — what a sheet
155    /// dimension on this placement is drawn at, so it reads at the size of the
156    /// annotations it sits among.
157    #[serde(rename = "textMm")]
158    pub text_mm: f64,
159    /// This placement IS a section view: the cut face's hatch and its caption
160    /// ([`SectionDrawing`]). `None` on a plain placement.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub section: Option<SectionDrawing>,
163    /// The section LINES drawn on THIS placement — one per section view cut
164    /// through it. A plain placement that nothing sections has none.
165    #[serde(rename = "sectionMarks", skip_serializing_if = "Vec::is_empty")]
166    pub section_marks: Vec<SectionMark>,
167    /// This placement IS a detail view: its boundary circle and its caption
168    /// ([`DetailDrawing`]). `None` on every other placement.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub detail: Option<DetailDrawing>,
171    /// The detail CIRCLES drawn on THIS placement — one per detail view drawn
172    /// from it.
173    #[serde(rename = "detailMarks", skip_serializing_if = "Vec::is_empty")]
174    pub detail_marks: Vec<DetailMark>,
175    /// `[x0, y0, x1, y1]` over everything drawn — empty geometry gives the
176    /// placement point twice.
177    pub bounds: [f64; 4],
178    /// Which hidden-line pass drew [`Self::edges`]: `analytic` (the exact
179    /// pass on the B-rep, [`super::hlr::visible_edges`]) or `mesh` (the same
180    /// pass on the display, [`super::hlr::visible_mesh_lines`] — the stated
181    /// approximation while a drawn solid's B-rep has not reached the scene).
182    /// Empty on a placement that did not project.
183    #[serde(rename = "hiddenLine", skip_serializing_if = "String::is_empty")]
184    pub hidden_line: String,
185    /// The saved view's PMI dimensions that carry a tolerance block (or read
186    /// as a reference dimension), by what they MEASURE on this paper: what a
187    /// sheet dimension anchored to the same points inherits
188    /// ([`super::dimension`]). Engine-side only — the drawing does not
189    /// publish them; a dimension that inherits says so on its own row.
190    #[serde(skip)]
191    pub tolerances: Vec<ToleranceSource>,
192}
193
194/// A PMI dimension of a placement's view with a tolerance block to hand on, by
195/// what it measures on the paper.
196#[derive(Debug, Clone, PartialEq)]
197pub struct ToleranceSource {
198    /// The annotation's id (`DIM4`).
199    pub id: String,
200    pub measures: Measures,
201    pub block: brep_kernel::ToleranceBlock,
202    pub reference: bool,
203}
204
205/// What a [`ToleranceSource`] measures, projected.
206#[derive(Debug, Clone, PartialEq)]
207pub enum Measures {
208    /// A free linear dimension between the paper images of its two measured
209    /// points. A component (X / Y / Z) row measures a different number than
210    /// any sheet dimension between the same two points can.
211    Linear { a: [f64; 2], b: [f64; 2] },
212    /// A radial or diametral dimension of the circle centred at the paper
213    /// image of `centre`, of MODEL radius `radius`.
214    Radial { centre: [f64; 2], radius: f64, diameter: bool },
215}
216
217impl ViewDrawing {
218    /// A placement with nothing drawn yet: its id, its position and a bounds
219    /// box of the placement point, so a view that draws nothing still has a
220    /// row and a grab rect on the paper.
221    fn empty(placed: &PlacedView, name: String) -> ViewDrawing {
222        ViewDrawing {
223            id: placed.id.clone(),
224            view: placed.view.clone(),
225            name,
226            position: placed.position,
227            scale: placed.scale,
228            error: String::new(),
229            edges: Vec::new(),
230            annotations: Vec::new(),
231            anchors: Vec::new(),
232            text_mm: 0.0,
233            section: None,
234            section_marks: Vec::new(),
235            detail: None,
236            detail_marks: Vec::new(),
237            bounds: [placed.position[0], placed.position[1], placed.position[0], placed.position[1]],
238            hidden_line: String::new(),
239            tolerances: Vec::new(),
240        }
241    }
242}
243
244/// A detail placement's own marks: the circle its drawing is clipped to and
245/// the `DETAIL B` / `SCALE 4:1` caption under it.
246///
247/// The model lines are not here — they are in [`ViewDrawing::edges`], already
248/// clipped to [`Self::circle`].
249#[derive(Debug, Clone, PartialEq, serde::Serialize)]
250pub struct DetailDrawing {
251    pub label: String,
252    /// The circle's centre on the paper — the placement's own position.
253    pub centre: [f64; 2],
254    /// The circle's radius on the paper: the model radius at this placement's
255    /// scale.
256    #[serde(rename = "radiusMm")]
257    pub radius_mm: f64,
258    /// The boundary, as a closed polyline.
259    pub circle: Vec<[f64; 2]>,
260    /// The caption: the letter, then the detail's own scale.
261    pub texts: Vec<SheetText>,
262}
263
264/// One detail CIRCLE, drawn on the placement it was picked on: the circle
265/// round the region the detail redraws, and its letter beside it.
266#[derive(Debug, Clone, PartialEq, serde::Serialize)]
267pub struct DetailMark {
268    /// The DETAIL placement's id — which view this circle is the region of.
269    pub id: String,
270    pub label: String,
271    pub centre: [f64; 2],
272    #[serde(rename = "radiusMm")]
273    pub radius_mm: f64,
274    pub lines: Vec<Vec<[f64; 2]>>,
275    pub texts: Vec<SheetText>,
276}
277
278/// A section placement's own marks: the hatch over its cut faces and the
279/// `SECTION A-A` caption under the drawing.
280///
281/// The cut OUTLINE is not here — it is in [`ViewDrawing::edges`] with every
282/// other model line, because the clip's new boundary edges go through the very
283/// same weld, classify and chain pass the rest of the model does. One
284/// extractor, so the ink and the hatch cannot disagree about where the cut is.
285#[derive(Debug, Clone, PartialEq, serde::Serialize)]
286pub struct SectionDrawing {
287    /// The section's letter.
288    pub label: String,
289    /// The cut face's hatch: parallel 45\u{00B0} lines clipped to the cut region by
290    /// an even-odd crossing count against [`Self::cut`].
291    pub hatch: Vec<Vec<[f64; 2]>>,
292    /// The cut region's boundary, in paper millimetres: one polyline per
293    /// trimmed piece of the plane's curve with a face, sampled from the exact
294    /// curve and oriented consistently round the region (their shoelace sum
295    /// is the cut area, the holes taken out). In the mesh approximation, one
296    /// segment per display triangle the plane cuts instead. What the hatch was
297    /// clipped to, and what a reader can check the hatched region against.
298    pub cut: Vec<Vec<[f64; 2]>>,
299    /// The `SECTION A-A` caption.
300    pub texts: Vec<SheetText>,
301}
302
303/// One section LINE, drawn on the placement the cut was picked in: the line
304/// through the two anchors, an arrow at each end pointing the way the section
305/// looks, and the letter beside each arrow.
306#[derive(Debug, Clone, PartialEq, serde::Serialize)]
307pub struct SectionMark {
308    /// The SECTION placement's id — which view this line is the cut of.
309    pub id: String,
310    pub label: String,
311    pub lines: Vec<Vec<[f64; 2]>>,
312    pub texts: Vec<SheetText>,
313}
314
315/// One annotation, laid out at sheet scale.
316#[derive(Debug, Clone, PartialEq, serde::Serialize)]
317pub struct AnnotationDrawing {
318    pub id: String,
319    #[serde(rename = "type")]
320    pub kind: String,
321    /// The kernel-formatted value — in MODEL units, never divided by the
322    /// sheet scale: a 10 mm feature drawn at 2:1 still reads `10.000`.
323    pub text: String,
324    /// Dimension / extension / leader lines, arrowhead outlines and frames.
325    pub lines: Vec<Vec<[f64; 2]>>,
326    pub texts: Vec<SheetText>,
327    /// What the projection has to say about this row, empty when it has
328    /// nothing: an annotation plane EDGE-ON to the view projects to a LINE,
329    /// so the row's geometry is a line and — with `flattenText` off — its
330    /// text falls on that line. Said out loud rather than dropped, which is
331    /// what the first sheets slice did with every non-parallel plane.
332    #[serde(skip_serializing_if = "String::is_empty")]
333    pub note: String,
334}
335
336/// One point of a placement's projection a sheet dimension can anchor to.
337///
338/// `anchor` is the reference string ([`super::dimension::SheetAnchor`]) — the
339/// name that survives a re-projection — and `at` is where it landed on the
340/// paper THIS time. The sheet viewport publishes one hit rect per candidate
341/// while the reference picker is up for a sheet object, and a dimension resolves its anchors by
342/// looking its references up in this list: an anchor that is not here is a
343/// LOST anchor, which is what marks its dimension unresolved.
344#[derive(Debug, Clone, PartialEq, serde::Serialize)]
345pub struct AnchorPoint {
346    #[serde(rename = "ref")]
347    pub anchor: String,
348    /// `vertex`, `edge` or `circle`.
349    pub kind: String,
350    /// Paper millimetres.
351    pub at: [f64; 2],
352    /// The 3D point before projection, in model units, with this view's poses
353    /// applied. Aligned dimensions measure here so an oblique camera cannot
354    /// foreshorten their values; `at` remains the point used for layout.
355    pub model: [f64; 3],
356    /// The unit PAPER direction of a STRAIGHT display edge — an `edge`
357    /// candidate only, and `None` on a curved edge or one that projects to a
358    /// point (an edge parallel to the viewing direction). It is what an
359    /// ANGULAR dimension measures between: the edge's direction as this view
360    /// projects it, so a foreshortened angle reads its projected value.
361    ///
362    /// Straightness is judged in the MODEL, not on the paper: a circular edge
363    /// seen edge-on projects to a straight segment and is not a straight edge.
364    /// All three fractions of one edge carry the same direction — it is the
365    /// EDGE's, not the point's.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub dir: Option<[f64; 2]>,
368    /// The circle's MODEL radius — a `circle` candidate only.
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub radius: Option<f64>,
371    /// The paper images of the circle's two conjugate radii: the projected
372    /// circle is the ellipse `at + u cos t + v sin t`. A `circle` candidate
373    /// only, and what a radial dimension's leader measures its reach along.
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub u: Option<[f64; 2]>,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub v: Option<[f64; 2]>,
378}
379
380/// A placed text run, centred on `anchor`.
381///
382/// The run is drawn in its OWN basis: [`SheetText::right`] and
383/// [`SheetText::up`] are the paper images of ONE CAP HEIGHT along the run's
384/// reading and up directions, in the drawing's millimetres (y DOWN, SVG's
385/// frame). A run laid flat on the sheet has `right = [h, 0]` and
386/// `up = [0, -h]`; a run projected out of an oblique annotation plane has a
387/// pair that is neither perpendicular nor equal in length, because the
388/// projection foreshortens and shears it, and the SVG and PDF writers carry
389/// the pair as a text matrix.
390///
391/// `height` and `angle` are that basis SUMMARISED — the cap height the run
392/// draws at (`|up|`) and the angle its baseline reads along, degrees
393/// CLOCKWISE (SVG's positive direction). They describe a flat or merely
394/// rotated run completely; the sheet viewport draws every run from them
395/// because egui cannot shear a text shape, so an oblique run previews as its
396/// rotation and its foreshortened height while the two files carry it exactly.
397#[derive(Debug, Clone, PartialEq, serde::Serialize)]
398pub struct SheetText {
399    pub text: String,
400    pub anchor: [f64; 2],
401    pub height: f64,
402    pub angle: f64,
403    pub right: [f64; 2],
404    pub up: [f64; 2],
405}
406
407impl SheetText {
408    /// A run from its projected basis; `height` and `angle` are derived here
409    /// and nowhere else, so the summary cannot disagree with the basis.
410    pub fn new(text: String, anchor: [f64; 2], right: [f64; 2], up: [f64; 2]) -> SheetText {
411        SheetText {
412            text,
413            anchor,
414            height: (up[0] * up[0] + up[1] * up[1]).sqrt(),
415            // atan2(0, 0) is 0: an EDGE-ON run has no reading direction left
416            // to report, and its row carries the note that says so.
417            angle: right[1].atan2(right[0]).to_degrees(),
418            right,
419            up,
420        }
421    }
422
423    /// A run laid FLAT on the sheet: reading left to right, `height`
424    /// millimetres of cap height. The title block's cells and every run of a
425    /// placement with `flattenText` on.
426    pub fn flat(text: String, anchor: [f64; 2], height: f64) -> SheetText {
427        SheetText::new(text, anchor, [height, 0.0], [0.0, -height])
428    }
429}
430
431// ---------------------------------------------------------------------------
432// The view frame
433// ---------------------------------------------------------------------------
434
435/// The saved camera as an orthonormal frame: `right` / `up` span the
436/// projection plane, `forward` runs eye → target. Points are measured from the
437/// camera's TARGET, which is the point the placement positions on the paper.
438#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
439pub struct ViewFrame {
440    pub target: [f64; 3],
441    pub right: [f64; 3],
442    pub up: [f64; 3],
443    pub forward: [f64; 3],
444}
445
446fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
447    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
448}
449
450fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
451    [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
452}
453
454fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
455    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
456}
457
458fn norm(v: [f64; 3]) -> [f64; 3] {
459    let len = dot(v, v).sqrt();
460    if len < 1e-12 {
461        [0.0, 0.0, 0.0]
462    } else {
463        [v[0] / len, v[1] / len, v[2] / len]
464    }
465}
466
467impl ViewFrame {
468    /// The frame of a saved camera. Mirrors [`crate::view::ViewCamera::basis`]
469    /// exactly (right = forward × up, true up = right × forward), so a sheet
470    /// view is oriented the way the viewport showed it.
471    pub fn of(camera: &PmiCamera) -> ViewFrame {
472        let forward = norm(sub(camera.target, camera.eye));
473        let forward = if forward == [0.0, 0.0, 0.0] { [0.0, 0.0, -1.0] } else { forward };
474        let mut right = norm(cross(forward, camera.up));
475        if right == [0.0, 0.0, 0.0] {
476            // The up vector is parallel to the view direction: any in-plane
477            // axis will do, and this one is continuous in the other cases.
478            right = norm(cross(forward, [0.0, 0.0, 1.0]));
479            if right == [0.0, 0.0, 0.0] {
480                right = norm(cross(forward, [0.0, 1.0, 0.0]));
481            }
482        }
483        let up = cross(right, forward);
484        ViewFrame { target: camera.target, right, up, forward }
485    }
486
487    /// `(x, y, depth)` in view space: x right, y UP, depth along the viewing
488    /// direction from the target plane (larger = further from the eye).
489    pub fn to_view(&self, p: [f64; 3]) -> [f64; 3] {
490        let d = sub(p, self.target);
491        [dot(d, self.right), dot(d, self.up), dot(d, self.forward)]
492    }
493}
494
495/// View-space `(x, y)` → paper millimetres for a placement.
496fn to_paper(placed: &PlacedView, x: f64, y: f64) -> [f64; 2] {
497    [placed.position[0] + x * placed.scale, placed.position[1] - y * placed.scale]
498}
499
500// ---------------------------------------------------------------------------
501// Entry
502// ---------------------------------------------------------------------------
503
504/// Project one placed view. `view` is the saved PMI view it names and `report`
505/// that view's resolved annotations (both `None` when the block no longer
506/// carries it, which is an error row rather than a dropped placement).
507pub fn project_view(
508    scene: &RenderScene,
509    placed: &PlacedView,
510    view: Option<&PmiView>,
511    report: Option<&PmiViewReport>,
512) -> ViewDrawing {
513    let (unposed, env) = (Unposed::new(), brep_kernel::Env::default());
514    project_view_through(&Model { scene, unposed: &unposed, env: &env }, placed, view, report, None, &mut LinesMode::Compute)
515}
516
517/// What a projection reads beside the sheet and its PMI views: the scene, the
518/// displays the viewport's active view posed in place (un-posed, see
519/// [`Unposed`]), and the document's expression sheet — a PMI row's tolerance
520/// may be an expression, and a sheet dimension that inherits it needs its
521/// value.
522pub struct Model<'a> {
523    pub scene: &'a RenderScene,
524    pub unposed: &'a Unposed,
525    pub env: &'a brep_kernel::Env,
526}
527
528/// The displays the viewport's ACTIVE PMI view has posed in place, un-posed —
529/// the engine's `pmi_explode_originals`. A sheet draws every placement with
530/// its OWN view's poses, so it reads a display through this first.
531pub type Unposed = std::collections::HashMap<String, crate::scene::SolidDisplay>;
532
533/// Which pass drew a placement's model lines ([`ViewDrawing::hidden_line`]).
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535pub enum HiddenLine {
536    /// The exact pass on the B-rep ([`super::hlr::visible_edges`]).
537    Analytic,
538    /// The same pass on the display ([`super::hlr::visible_mesh_lines`]): the
539    /// stated approximation while a drawn solid's B-rep is not in the scene.
540    Mesh,
541}
542
543impl HiddenLine {
544    pub fn label(self) -> &'static str {
545        match self {
546            HiddenLine::Analytic => "analytic",
547            HiddenLine::Mesh => "mesh",
548        }
549    }
550}
551
552/// What a SECTION placement projects through, once its cut has been resolved
553/// against the source placement's projection: the derived camera frame, the
554/// half-space to keep, and the letter.
555pub struct SectionSetup {
556    pub frame: ViewFrame,
557    pub plane: ClipPlane,
558    pub label: String,
559}
560
561/// What a DETAIL placement projects through, once its circle has been
562/// resolved against the source placement's projection: the source's own
563/// camera frame re-targeted on the circle's centre, and the circle's radius
564/// in MODEL units.
565pub struct DetailSetup {
566    pub frame: ViewFrame,
567    pub radius: f64,
568    pub label: String,
569}
570
571/// A placement whose camera is DERIVED from another placement rather than
572/// read off its saved view.
573pub enum Derived {
574    Section(SectionSetup),
575    Detail(DetailSetup),
576}
577
578/// Project one placed view, optionally through a DERIVED camera — a
579/// section's or a detail's — instead of its saved one.
580///
581/// With a SECTION, the frame is the derived one, the model is cut by the
582/// section plane exactly ([`super::hlr::section_curves`]), and the pass keeps
583/// only what is at or beyond the plane with the cut region as a cap in front
584/// of what lies behind it. The candidate, split and visibility code and the
585/// ANCHOR space are the very same as a plain placement's.
586///
587/// With a DETAIL, the frame is the source's camera re-targeted on the circle's
588/// centre (so this placement's `position` is where the centre lands), and
589/// visibility is decided inside the circle's cylinder at the detail's own
590/// scale; [`clip_to_circle`] then trims only a chord that bows past the rim.
591///
592/// Both draw the model ONLY: the saved view's annotations were laid out
593/// against THAT view's camera and scale, and a section's camera is a different
594/// one, while a detail's circle would cut a dimension or a frame in half.
595pub fn project_view_through(
596    model: &Model,
597    placed: &PlacedView,
598    view: Option<&PmiView>,
599    report: Option<&PmiViewReport>,
600    derived: Option<Derived>,
601    mode: &mut LinesMode,
602) -> ViewDrawing {
603    let (scene, unposed) = (model.scene, model.unposed);
604    let mut out = ViewDrawing::empty(placed, view.map(|v| v.name.clone()).unwrap_or_default());
605    let Some(view) = view else {
606        out.error = format!("no PMI view '{}'", placed.view);
607        return out;
608    };
609    let Some(camera) = view.camera.as_ref() else {
610        out.error = format!("PMI view '{}' has no captured camera", placed.view);
611        return out;
612    };
613    if !matches!(camera.projection, PmiProjection::Orthographic { .. }) {
614        out.error = format!(
615            "PMI view '{}' is a perspective camera; a sheet draws {ORTHOGRAPHIC} views only",
616            placed.view
617        );
618        return out;
619    }
620    if !placed.projection.eq_ignore_ascii_case(ORTHOGRAPHIC) {
621        out.error = format!("projection '{}' is not drawn on a sheet", placed.projection);
622        return out;
623    }
624
625    let saved_section = derived.is_none() && view.display.section.is_some();
626    let derived = derived.or_else(|| view.section_plane().map(|(point, normal)| {
627        let original = ViewFrame::of(camera);
628        let seed = if dot(original.up, normal).abs() < 0.99 { original.up } else if normal[0].abs() < 0.9 { [1., 0., 0.] } else { [0., 1., 0.] };
629        let right = norm(cross(normal, seed));
630        Derived::Section(SectionSetup { frame: ViewFrame { target: point, right, up: cross(right, normal), forward: normal }, plane: ClipPlane { point, normal }, label: view.name.clone() })
631    }));
632    let frame = match &derived {
633        Some(Derived::Section(setup)) => setup.frame,
634        Some(Derived::Detail(setup)) => setup.frame,
635        None => ViewFrame::of(camera),
636    };
637    out.text_mm = view.display.text_size_pt * MM_PER_POINT;
638    let cap = if out.text_mm > 1e-9 { out.text_mm } else { 12.0 * MM_PER_POINT };
639    // The exact pass needs every drawn solid's B-rep; a solid whose topology
640    // did not reach the scene sends the placement to the mesh approximation,
641    // and so does a pass handed to the runner that has not come back yet. The
642    // placement says which drew it rather than drawing that solid as nothing.
643    let plan = exact_solids(scene, view, report);
644    let clip = match &derived {
645        None => LinesClip::Whole,
646        Some(Derived::Detail(setup)) => LinesClip::Circle { radius: setup.radius },
647        Some(Derived::Section(_)) => LinesClip::Section,
648    };
649    let exact = plan.as_ref().and_then(|plan| exact_lines_for(plan, &frame, placed, clip, mode));
650    out.hidden_line = if exact.is_some() { HiddenLine::Analytic } else { HiddenLine::Mesh }.label().into();
651    match &derived {
652        None => {
653            out.edges = match exact {
654                Some(lines) => lines.runs,
655                None => mesh_lines_for(scene, unposed, view, report, &frame, placed, LinesClip::Whole, None).runs,
656            };
657            out.annotations = annotations(report, view, &frame, placed);
658            out.tolerances = tolerance_sources(report, view, &frame, placed, model.env);
659        }
660        Some(Derived::Detail(setup)) => {
661            let radius_mm = setup.radius * placed.scale;
662            // Analytic: visibility is decided on the model inside the
663            // circle's cylinder, sampled at THIS placement's scale. The
664            // circle clip after it only trims a chord that bows past the rim.
665            let lines = match exact {
666                Some(lines) => lines.runs,
667                None => mesh_lines_for(scene, unposed, view, report, &frame, placed, clip, None).runs,
668            };
669            out.edges = clip_to_circle(lines, placed.position, radius_mm);
670            out.detail = Some(DetailDrawing {
671                label: setup.label.clone(),
672                centre: placed.position,
673                radius_mm,
674                circle: circle_polyline(placed.position, radius_mm),
675                texts: Vec::new(),
676            });
677        }
678        Some(Derived::Section(setup)) => match exact {
679            Some(ExactLines { runs, cut }) => {
680                // The cut curves are model lines in their own right: nothing
681                // is in front of a cut face to hide them.
682                let mut edges = runs;
683                edges.extend(cut.iter().cloned());
684                out.edges = edges;
685                out.section = Some(SectionDrawing {
686                    label: setup.label.clone(),
687                    hatch: hatch(&segments_of(&cut)),
688                    cut,
689                    texts: Vec::new(),
690                });
691            }
692            None => {
693                let ExactLines { runs, cut: cut_paper } =
694                    mesh_lines_for(scene, unposed, view, report, &frame, placed, clip, Some(&setup.plane));
695                let mut edges = runs;
696                edges.extend(cut_paper.iter().cloned());
697                out.edges = edges;
698                out.section = Some(SectionDrawing {
699                    label: setup.label.clone(),
700                    hatch: hatch(&cut_paper),
701                    cut: cut_paper,
702                    texts: Vec::new(),
703                });
704            }
705        },
706    }
707    if saved_section {
708        out.annotations = annotations(report, view, &frame, placed);
709        out.tolerances = tolerance_sources(report, view, &frame, placed, model.env);
710    }
711    out.anchors = anchor_points(scene, unposed, view, report, &frame, placed);
712    // A detail offers the anchors INSIDE its circle and no others. The anchor
713    // space is still the model's topology — the hidden-line pass has no vote
714    // on it, here as everywhere — but a point outside the circle is not on
715    // this placement at all: nothing of the detail is drawn there, and at the
716    // detail's scale it would land anywhere on the paper, over other views,
717    // where the sheet picker's markers would offer it as a point of theirs.
718    if let Some(detail) = &out.detail {
719        let reach = detail.radius_mm * (1.0 + 1e-9) + 1e-9;
720        out.anchors.retain(|anchor| {
721            let (dx, dy) = (anchor.at[0] - detail.centre[0], anchor.at[1] - detail.centre[1]);
722            (dx * dx + dy * dy).sqrt() <= reach
723        });
724    }
725    out.bounds = bounds_of(&out, placed);
726    // The caption goes UNDER the finished drawing, so it is placed once the
727    // bounds are known — and the bounds are then re-read to take it in, or a
728    // placement drag would grab a rect the caption hangs out of.
729    let middle = (out.bounds[0] + out.bounds[2]) * 0.5;
730    let below = out.bounds[3] + cap * 2.0;
731    if let Some(section) = out.section.as_mut() {
732        section.texts.push(SheetText::flat(
733            format!("SECTION {}\u{2014}{}", section.label, section.label),
734            [middle, below],
735            cap,
736        ));
737        out.bounds = bounds_of(&out, placed);
738    }
739    // A detail's caption says which circle it is and — the one thing that
740    // distinguishes it from its source — the scale it is drawn at.
741    if let Some(detail) = out.detail.as_mut() {
742        detail.texts.push(SheetText::flat(format!("DETAIL {}", detail.label), [middle, below], cap));
743        detail.texts.push(SheetText::flat(
744            format!("SCALE {}", super::frame::ratio_text(placed.scale)),
745            [middle, below + cap * 1.8],
746            cap * 0.8,
747        ));
748        out.bounds = bounds_of(&out, placed);
749    }
750    out
751}
752
753/// The whole sheet: the paper's furniture, then every placement projected, in
754/// order. `context` is what the border and title block need and a projection
755/// cannot know — the document's name and the day (see
756/// [`super::frame::FrameContext`]). Every exact pass runs HERE, on the calling
757/// thread; [`project_sheet_with`] can hand them over instead.
758pub fn project_sheet(
759    scene: &RenderScene,
760    sheet: &super::Sheet,
761    views: &[PmiView],
762    report: Option<&brep_kernel::PmiReport>,
763    context: &super::frame::FrameContext,
764) -> SheetDrawing {
765    let (unposed, env) = (Unposed::new(), brep_kernel::Env::default());
766    project_sheet_with(&Model { scene, unposed: &unposed, env: &env }, sheet, views, report, context, &mut LinesMode::Compute)
767}
768
769/// [`project_sheet`], with what an exact-lines miss does chosen by `mode`: the
770/// engine's viewport hands the pass to the runner and draws the mesh
771/// approximation until the answer lands; an export computes it here.
772pub fn project_sheet_with(
773    model: &Model,
774    sheet: &super::Sheet,
775    views: &[PmiView],
776    report: Option<&brep_kernel::PmiReport>,
777    context: &super::frame::FrameContext,
778    mode: &mut LinesMode,
779) -> SheetDrawing {
780    let (width_mm, height_mm) = sheet.millimetres();
781    // PLAIN placements first, then the DERIVED ones: a section's camera and a
782    // detail's circle are both read off where their anchors landed in the
783    // source's projection, so the source has to be drawn before either can
784    // know where it is looking from. Every derived placement's source must be
785    // a PLAIN one — a section or a detail OF a section or a detail is refused
786    // by name rather than ordered, because this pass is one pass and a chain
787    // would need as many.
788    let mut drawn: Vec<ViewDrawing> = sheet
789        .views
790        .iter()
791        .map(|placed| {
792            let saved = views.iter().find(|view| view.id == placed.view);
793            if placed.section.is_some() || placed.detail.is_some() {
794                // A stand-in until the second pass: it holds the id and the
795                // position, so a derived view that cannot resolve still has a
796                // row and a grab rect on the paper.
797                ViewDrawing::empty(placed, saved.map(|v| v.name.clone()).unwrap_or_default())
798            } else {
799                project_view_through(model, placed, saved, report.and_then(|report| report.view(&placed.view)), None, mode)
800            }
801        })
802        .collect();
803    for (index, placed) in sheet.views.iter().enumerate() {
804        let Some(circle) = placed.detail.as_ref() else { continue };
805        match detail_setup(sheet, &drawn, circle, views) {
806            Err(error) => drawn[index].error = error,
807            Ok((setup, source, mark)) => {
808                drawn[index] = project_view_through(
809                    model,
810                    placed,
811                    views.iter().find(|view| view.id == placed.view),
812                    report.and_then(|report| report.view(&placed.view)),
813                    Some(Derived::Detail(setup)),
814                    mode,
815                );
816                let mark = DetailMark { id: placed.id.clone(), ..mark };
817                drawn[source].detail_marks.push(mark);
818                let source_placed = &sheet.views[source];
819                drawn[source].bounds = bounds_of(&drawn[source], source_placed);
820            }
821        }
822    }
823    for (index, placed) in sheet.views.iter().enumerate() {
824        let Some(cut) = placed.section.as_ref() else { continue };
825        match section_setup(sheet, &drawn, placed, cut, views) {
826            Err(error) => drawn[index].error = error,
827            Ok((setup, source, mark)) => {
828                drawn[index] = project_view_through(
829                    model,
830                    placed,
831                    views.iter().find(|view| view.id == placed.view),
832                    report.and_then(|report| report.view(&placed.view)),
833                    Some(Derived::Section(setup)),
834                    mode,
835                );
836                // The LINE goes on the source, where it was picked. Its own
837                // bounds grow to take it in, so dragging that placement still
838                // grabs everything it draws.
839                drawn[source].section_marks.push(mark);
840                let source_placed = &sheet.views[source];
841                drawn[source].bounds = bounds_of(&drawn[source], source_placed);
842            }
843        }
844    }
845    // The sheet's own dimensions come LAST: they anchor to the placements'
846    // projections, so they are laid out over the finished drawing rather than
847    // inside any one placement.
848    let dimensions = super::dimension::layout_dimensions(
849        &sheet.dimensions,
850        &drawn,
851        [width_mm * 0.5, height_mm * 0.5],
852    );
853    let ordinates = super::ordinate::layout_ordinates(
854        &sheet.ordinates,
855        &drawn,
856        [width_mm * 0.5, height_mm * 0.5],
857    );
858    SheetDrawing {
859        id: sheet.id.clone(),
860        name: sheet.name.clone(),
861        width_mm,
862        height_mm,
863        frame: super::frame::frame(sheet, context),
864        views: drawn,
865        dimensions,
866        ordinates,
867    }
868}
869
870/// Resolve a section's cut against the SOURCE placement's finished projection:
871/// the derived camera frame, the half-space to keep, which placement carries
872/// the line, and the line itself.
873///
874/// # The camera
875///
876/// The two anchors are paper points of the source's projection. Lifted back
877/// into the source camera's own plane they give two 3D points, and the CUTTING
878/// PLANE is the plane through them containing the source's viewing direction —
879/// which is what "a line drawn on a view" means: every point of the paper line
880/// is a whole ray of the model.
881///
882/// The section camera then looks along that plane's NORMAL, and its up is the
883/// source's viewing direction REVERSED. Those two are perpendicular for free,
884/// because the plane contains the source's viewing direction and its normal is
885/// perpendicular to everything in it — so the frame is orthonormal by
886/// construction rather than by a re-orthogonalisation that could quietly tilt
887/// the section. It is also the drawing convention: section a TOP view and the
888/// section is an elevation with model up ON the page.
889///
890/// # Which way it looks
891///
892/// 90\u{00B0} clockwise from `from` \u{2192} `to` on the SOURCE's paper — the side a reader
893/// walking from the first arrow to the second has on their right. The pick
894/// ORDER is therefore the choice, and `flip` is the form's way to change its
895/// mind without re-picking.
896fn section_setup(
897    sheet: &super::Sheet,
898    drawn: &[ViewDrawing],
899    placed: &PlacedView,
900    cut: &super::SectionCut,
901    views: &[PmiView],
902) -> Result<(SectionSetup, usize, SectionMark), String> {
903    // A section the Section view button made has no line until its form's
904    // Cutting line row is picked — say what it is waiting for.
905    if cut.from.is_empty() || cut.to.is_empty() {
906        return Err("no cutting line yet \u{2014} pick its two anchors on a placed view".into());
907    }
908    let Some(source_index) = sheet.views.iter().position(|v| v.id == cut.source) else {
909        return Err(format!("no placement '{}' to cut through on this sheet", cut.source));
910    };
911    let source_placed = &sheet.views[source_index];
912    if source_placed.section.is_some() {
913        return Err(format!(
914            "'{}' is itself a section; a section of a section is not drawn",
915            cut.source
916        ));
917    }
918    if source_placed.detail.is_some() {
919        return Err(format!(
920            "'{}' is a detail; a section of a detail is not drawn",
921            cut.source
922        ));
923    }
924    let source = &drawn[source_index];
925    if !source.error.is_empty() {
926        return Err(format!("placement '{}' did not project", cut.source));
927    }
928    let (from, to) = (point_of(source, &cut.from)?, point_of(source, &cut.to)?);
929    let span = [to[0] - from[0], to[1] - from[1]];
930    let length = (span[0] * span[0] + span[1] * span[1]).sqrt();
931    if !(length > 1e-9) {
932        return Err(format!(
933            "'{}' and '{}' are the same point; a cutting line needs two",
934            cut.from, cut.to
935        ));
936    }
937    let Some(camera) = views
938        .iter()
939        .find(|view| view.id == source_placed.view)
940        .and_then(|view| view.camera.as_ref())
941    else {
942        return Err(format!("placement '{}' has no captured camera to cut in", cut.source));
943    };
944    let source_frame = ViewFrame::of(camera);
945    if source_placed.scale <= 0.0 {
946        return Err(format!("placement '{}' has no scale", cut.source));
947    }
948    // Paper -> the source camera's own plane. The depth along the source's
949    // viewing direction is free — the cutting plane contains that direction,
950    // so every choice names the same plane — and the target plane is the one
951    // choice with no arbitrary number in it.
952    let (p0, p1) = (lift(&source_frame, source_placed, from), lift(&source_frame, source_placed, to));
953    let along = norm(sub(p1, p0));
954    let mut forward = norm(cross(source_frame.forward, along));
955    if forward == [0.0, 0.0, 0.0] {
956        return Err("the cutting line has no direction in this view".into());
957    }
958    if cut.flip {
959        forward = [-forward[0], -forward[1], -forward[2]];
960    }
961    let up = [-source_frame.forward[0], -source_frame.forward[1], -source_frame.forward[2]];
962    let right = norm(cross(forward, up));
963    let target = [(p0[0] + p1[0]) * 0.5, (p0[1] + p1[1]) * 0.5, (p0[2] + p1[2]) * 0.5];
964    let setup = SectionSetup {
965        frame: ViewFrame { target, right, up: cross(right, forward), forward },
966        plane: ClipPlane { point: target, normal: forward },
967        label: cut.label.clone(),
968    };
969    let cap = if source.text_mm > 1e-9 { source.text_mm } else { 12.0 * MM_PER_POINT };
970    Ok((setup, source_index, section_mark(placed, cut, from, to, cap)))
971}
972
973/// The section LINE on the source's paper: the line through the two anchors
974/// with a tail past each, an arrow at each end pointing the way the section
975/// looks, and the letter beside each arrow.
976fn section_mark(
977    placed: &PlacedView,
978    cut: &super::SectionCut,
979    from: [f64; 2],
980    to: [f64; 2],
981    cap: f64,
982) -> SectionMark {
983    let span = [to[0] - from[0], to[1] - from[1]];
984    let length = (span[0] * span[0] + span[1] * span[1]).sqrt().max(1e-9);
985    let along = [span[0] / length, span[1] / length];
986    // The viewing direction, as the SOURCE's paper sees it: 90 degrees
987    // clockwise from the travel direction (paper y runs DOWN, so that is
988    // `(-dy, dx)`), turned round by `flip`. Derived here from the same pick
989    // order the camera is, so the arrows point where the section looks.
990    let sign = if cut.flip { -1.0 } else { 1.0 };
991    let look = [-along[1] * sign, along[0] * sign];
992    let tail = cap * 1.5;
993    let arrow = cap * 1.2;
994    let mut lines = vec![vec![
995        [from[0] - along[0] * tail, from[1] - along[1] * tail],
996        [to[0] + along[0] * tail, to[1] + along[1] * tail],
997    ]];
998    let mut texts = Vec::new();
999    for (end, outward) in [(from, [-along[0], -along[1]]), (to, along)] {
1000        let root = [end[0] + outward[0] * tail, end[1] + outward[1] * tail];
1001        let tip = [root[0] + look[0] * arrow * 2.0, root[1] + look[1] * arrow * 2.0];
1002        // The stem, then the head as a closed outline — the sheet's contract
1003        // is lines, so an arrowhead is its own boundary here exactly as it is
1004        // in the shared dimension layout.
1005        lines.push(vec![root, tip]);
1006        let across = [-look[1] * arrow * 0.35, look[0] * arrow * 0.35];
1007        lines.push(vec![
1008            tip,
1009            [tip[0] - look[0] * arrow + across[0], tip[1] - look[1] * arrow + across[1]],
1010            [tip[0] - look[0] * arrow - across[0], tip[1] - look[1] * arrow - across[1]],
1011            tip,
1012        ]);
1013        texts.push(SheetText::flat(
1014            cut.label.clone(),
1015            [
1016                root[0] + outward[0] * cap * 1.2 + look[0] * cap * 0.6,
1017                root[1] + outward[1] * cap * 1.2 + look[1] * cap * 0.6,
1018            ],
1019            cap,
1020        ));
1021    }
1022    SectionMark { id: placed.id.clone(), label: cut.label.clone(), lines, texts }
1023}
1024
1025/// Where anchor `reference` landed on a finished placement's paper, or the
1026/// reason a derived view built on it cannot resolve.
1027fn point_of(source: &ViewDrawing, reference: &str) -> Result<[f64; 2], String> {
1028    source
1029        .anchors
1030        .iter()
1031        .find(|a| a.anchor == reference)
1032        .map(|a| a.at)
1033        .ok_or_else(|| format!("'{reference}' is not a point of '{}' any more", source.id))
1034}
1035
1036/// A paper point of `placed` lifted back into its camera's TARGET plane: the
1037/// inverse of [`to_paper`] with the depth set to zero.
1038fn lift(frame: &ViewFrame, placed: &PlacedView, p: [f64; 2]) -> [f64; 3] {
1039    let x = (p[0] - placed.position[0]) / placed.scale;
1040    let y = -(p[1] - placed.position[1]) / placed.scale;
1041    [
1042        frame.target[0] + frame.right[0] * x + frame.up[0] * y,
1043        frame.target[1] + frame.right[1] * x + frame.up[1] * y,
1044        frame.target[2] + frame.right[2] * x + frame.up[2] * y,
1045    ]
1046}
1047
1048/// Resolve a detail's circle against the SOURCE placement's finished
1049/// projection: the camera the detail looks through, which placement carries
1050/// the circle, and the circle itself as drawn there.
1051///
1052/// # The camera
1053///
1054/// The SOURCE's, unchanged in every direction: the detail is the same view at
1055/// a larger scale, so its right, up and viewing direction are the source's.
1056/// Only the TARGET moves — to the circle's centre, lifted back into the source
1057/// camera's own plane — which is what makes this placement's `position` the
1058/// point the circle's centre lands on. The depth of that target is free (a
1059/// parallel projection does not care where along the viewing direction the
1060/// target plane is), and the source's own target plane is the choice with no
1061/// arbitrary number in it.
1062///
1063/// # The radius
1064///
1065/// The distance between the two anchors on the SOURCE's paper over the
1066/// source's scale: a model length, so the circle holds the same region of the
1067/// model at any detail scale. Measured in the projection plane, which is where
1068/// the circle is.
1069fn detail_setup(
1070    sheet: &super::Sheet,
1071    drawn: &[ViewDrawing],
1072    circle: &super::DetailCircle,
1073    views: &[PmiView],
1074) -> Result<(DetailSetup, usize, DetailMark), String> {
1075    // A detail the Detail view button made has no circle until its form's
1076    // Centre and rim rows are picked.
1077    if circle.centre.is_empty() || circle.rim.is_empty() {
1078        return Err("no detail circle yet \u{2014} pick its centre and a point on its rim on a placed view".into());
1079    }
1080    let Some(source_index) = sheet.views.iter().position(|v| v.id == circle.source) else {
1081        return Err(format!("no placement '{}' to draw a detail of on this sheet", circle.source));
1082    };
1083    let source_placed = &sheet.views[source_index];
1084    if source_placed.detail.is_some() {
1085        return Err(format!(
1086            "'{}' is itself a detail; a detail of a detail is not drawn",
1087            circle.source
1088        ));
1089    }
1090    if source_placed.section.is_some() {
1091        return Err(format!(
1092            "'{}' is a section; a detail of a section is not drawn",
1093            circle.source
1094        ));
1095    }
1096    let source = &drawn[source_index];
1097    if !source.error.is_empty() {
1098        return Err(format!("placement '{}' did not project", circle.source));
1099    }
1100    let (centre, rim) = (point_of(source, &circle.centre)?, point_of(source, &circle.rim)?);
1101    let radius_mm = ((rim[0] - centre[0]).powi(2) + (rim[1] - centre[1]).powi(2)).sqrt();
1102    if !(radius_mm > 1e-9) {
1103        return Err(format!(
1104            "'{}' and '{}' land on the same point of '{}'; a detail circle needs a radius",
1105            circle.centre, circle.rim, circle.source
1106        ));
1107    }
1108    let Some(camera) = views
1109        .iter()
1110        .find(|view| view.id == source_placed.view)
1111        .and_then(|view| view.camera.as_ref())
1112    else {
1113        return Err(format!("placement '{}' has no captured camera to draw a detail of", circle.source));
1114    };
1115    if source_placed.scale <= 0.0 {
1116        return Err(format!("placement '{}' has no scale", circle.source));
1117    }
1118    let source_frame = ViewFrame::of(camera);
1119    let setup = DetailSetup {
1120        frame: ViewFrame { target: lift(&source_frame, source_placed, centre), ..source_frame },
1121        radius: radius_mm / source_placed.scale,
1122        label: circle.label.clone(),
1123    };
1124    let cap = if source.text_mm > 1e-9 { source.text_mm } else { 12.0 * MM_PER_POINT };
1125    // The letter sits outside the circle up and to the right, a cap and a
1126    // half clear of the rim, so it names the circle without touching what the
1127    // circle encloses.
1128    let d = std::f64::consts::FRAC_1_SQRT_2;
1129    let reach = radius_mm + cap * 1.5;
1130    let mark = DetailMark {
1131        id: String::new(),
1132        label: circle.label.clone(),
1133        centre,
1134        radius_mm,
1135        lines: vec![circle_polyline(centre, radius_mm)],
1136        texts: vec![SheetText::flat(
1137            circle.label.clone(),
1138            [centre[0] + reach * d, centre[1] - reach * d],
1139            cap,
1140        )],
1141    };
1142    Ok((setup, source_index, mark))
1143}
1144
1145/// How many straight pieces a detail circle is drawn with. A circle is a
1146/// polyline on a sheet like every other curve; at this count a 100 mm circle
1147/// misses the true one by 0.05 mm, under a stroke width.
1148const CIRCLE_SEGMENTS: usize = 96;
1149
1150/// A closed circle polyline in paper millimetres, back to its first point.
1151fn circle_polyline(centre: [f64; 2], radius: f64) -> Vec<[f64; 2]> {
1152    (0..=CIRCLE_SEGMENTS)
1153        .map(|k| {
1154            let t = std::f64::consts::TAU * k as f64 / CIRCLE_SEGMENTS as f64;
1155            [centre[0] + radius * t.cos(), centre[1] + radius * t.sin()]
1156        })
1157        .collect()
1158}
1159
1160/// Keep the parts of `lines` inside the circle, splitting a polyline wherever
1161/// it leaves and re-enters.
1162///
1163/// Exact per segment — each piece is cut where it crosses the TRUE circle, not
1164/// the polyline it is drawn with — so a line leaving the detail ends on the
1165/// boundary a reader sees rather than a stroke short of it. A segment lying
1166/// wholly inside keeps its own two ends, so a straight edge inside the circle
1167/// comes back as the two points it went in as.
1168fn clip_to_circle(lines: Vec<Vec<[f64; 2]>>, centre: [f64; 2], radius: f64) -> Vec<Vec<[f64; 2]>> {
1169    let mut out: Vec<Vec<[f64; 2]>> = Vec::new();
1170    let flush = |current: &mut Vec<[f64; 2]>, out: &mut Vec<Vec<[f64; 2]>>| {
1171        if current.len() >= 2 {
1172            out.push(std::mem::take(current));
1173        } else {
1174            current.clear();
1175        }
1176    };
1177    for line in lines {
1178        let mut current: Vec<[f64; 2]> = Vec::new();
1179        for pair in line.windows(2) {
1180            let (p, q) = (pair[0], pair[1]);
1181            let d = [q[0] - p[0], q[1] - p[1]];
1182            let f = [p[0] - centre[0], p[1] - centre[1]];
1183            let a = d[0] * d[0] + d[1] * d[1];
1184            let c = f[0] * f[0] + f[1] * f[1] - radius * radius;
1185            // The parameter span of the segment inside the circle, if any.
1186            let span = if a < 1e-24 {
1187                (c <= 0.0).then_some((0.0, 1.0))
1188            } else {
1189                let b = 2.0 * (f[0] * d[0] + f[1] * d[1]);
1190                let disc = b * b - 4.0 * a * c;
1191                if disc < 0.0 {
1192                    None
1193                } else {
1194                    let root = disc.sqrt();
1195                    let (t0, t1) = (((-b - root) / (2.0 * a)).max(0.0), ((-b + root) / (2.0 * a)).min(1.0));
1196                    (t1 > t0).then_some((t0, t1))
1197                }
1198            };
1199            let at = |t: f64| [p[0] + d[0] * t, p[1] + d[1] * t];
1200            match span {
1201                None => flush(&mut current, &mut out),
1202                Some((t0, t1)) => {
1203                    if t0 <= 0.0 {
1204                        if current.is_empty() {
1205                            current.push(p);
1206                        }
1207                        if t1 >= 1.0 {
1208                            current.push(q);
1209                        } else {
1210                            current.push(at(t1));
1211                            flush(&mut current, &mut out);
1212                        }
1213                    } else {
1214                        flush(&mut current, &mut out);
1215                        current.push(at(t0));
1216                        if t1 >= 1.0 {
1217                            current.push(q);
1218                        } else {
1219                            current.push(at(t1));
1220                            flush(&mut current, &mut out);
1221                        }
1222                    }
1223                }
1224            }
1225        }
1226        flush(&mut current, &mut out);
1227    }
1228    out
1229}
1230
1231fn bounds_of(view: &ViewDrawing, placed: &PlacedView) -> [f64; 4] {
1232    let mut bounds = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY];
1233    let mut add = |p: [f64; 2]| {
1234        bounds[0] = bounds[0].min(p[0]);
1235        bounds[1] = bounds[1].min(p[1]);
1236        bounds[2] = bounds[2].max(p[0]);
1237        bounds[3] = bounds[3].max(p[1]);
1238    };
1239    for polyline in &view.edges {
1240        for point in polyline {
1241            add(*point);
1242        }
1243    }
1244    for annotation in &view.annotations {
1245        for polyline in &annotation.lines {
1246            for point in polyline {
1247                add(*point);
1248            }
1249        }
1250        for text in &annotation.texts {
1251            add(text.anchor);
1252        }
1253    }
1254    if let Some(section) = &view.section {
1255        for polyline in section.hatch.iter().chain(section.cut.iter()) {
1256            for point in polyline {
1257                add(*point);
1258            }
1259        }
1260        for text in &section.texts {
1261            add(text.anchor);
1262        }
1263    }
1264    for mark in &view.section_marks {
1265        for polyline in &mark.lines {
1266            for point in polyline {
1267                add(*point);
1268            }
1269        }
1270        for text in &mark.texts {
1271            add(text.anchor);
1272        }
1273    }
1274    if let Some(detail) = &view.detail {
1275        for point in &detail.circle {
1276            add(*point);
1277        }
1278        for text in &detail.texts {
1279            add(text.anchor);
1280        }
1281    }
1282    for mark in &view.detail_marks {
1283        for polyline in &mark.lines {
1284            for point in polyline {
1285                add(*point);
1286            }
1287        }
1288        for text in &mark.texts {
1289            add(text.anchor);
1290        }
1291    }
1292    if bounds[0] > bounds[2] {
1293        [placed.position[0], placed.position[1], placed.position[0], placed.position[1]]
1294    } else {
1295        bounds
1296    }
1297}
1298
1299// ---------------------------------------------------------------------------
1300// The exact solids
1301// ---------------------------------------------------------------------------
1302
1303/// One explode row's pose of a drawn solid: `p' = R((p − c) ∘ s) + c + t`, the
1304/// viewport's own formula (`pmi_ops`), read off the view's report.
1305#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1306pub struct SolidPose {
1307    pub translate: [f64; 3],
1308    #[serde(rename = "rotateDeg")]
1309    pub rotate_deg: [f64; 3],
1310    pub scale: [f64; 3],
1311    pub center: [f64; 3],
1312}
1313
1314impl SolidPose {
1315    pub fn apply(&self, p: [f64; 3]) -> [f64; 3] {
1316        let (center, scale, translate) = (self.center, self.scale, self.translate);
1317        let local = [(p[0] - center[0]) * scale[0], (p[1] - center[1]) * scale[1], (p[2] - center[2]) * scale[2]];
1318        let rotated = crate::engine_state::rotate_euler_xyz_f64(local, self.rotate_deg);
1319        [
1320            rotated[0] + center[0] + translate[0],
1321            rotated[1] + center[1] + translate[1],
1322            rotated[2] + center[2] + translate[2],
1323        ]
1324    }
1325
1326    /// The pose to the bit, as it enters an identity.
1327    fn identity(&self) -> String {
1328        let bits = |v: &[f64; 3]| format!("{:x},{:x},{:x}", v[0].to_bits(), v[1].to_bits(), v[2].to_bits());
1329        format!(
1330            "pose[{}|{}|{}|{}];",
1331            bits(&self.translate),
1332            bits(&self.rotate_deg),
1333            bits(&self.scale),
1334            bits(&self.center)
1335        )
1336    }
1337}
1338
1339/// Every pose the view's enabled, resolved explode rows apply to `name`, in
1340/// row order — what the exact pass and the mesh approximation both draw a
1341/// solid with.
1342pub(crate) fn poses_of(report: Option<&PmiViewReport>, name: &str) -> Vec<SolidPose> {
1343    let Some(report) = report else { return Vec::new() };
1344    report
1345        .annotations
1346        .iter()
1347        .filter(|row| row.enabled && row.status == PmiStatus::Ok)
1348        .filter_map(|row| match &row.geometry {
1349            brep_kernel::PmiGeometry::Explode { solids, translate, rotate_deg, scale, center, .. }
1350                if solids.iter().any(|target| target == name) =>
1351            {
1352                Some(SolidPose { translate: *translate, rotate_deg: *rotate_deg, scale: *scale, center: *center })
1353            }
1354            _ => None,
1355        })
1356        .collect()
1357}
1358
1359/// One solid of an exact pass, by what the RUNNER holds it as: its name, the
1360/// resident handle, the content fingerprint the main side read off its copy,
1361/// and the poses the view applies to it.
1362#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1363pub struct LinesSolid {
1364    pub name: String,
1365    pub handle: u32,
1366    pub fingerprint: u64,
1367    pub poses: Vec<SolidPose>,
1368}
1369
1370impl LinesSolid {
1371    /// What the drawn geometry IS: the content fingerprint and every pose
1372    /// applied to it, to the bit. Not the resident handle: a handle is unique
1373    /// only within one runner's registry, and a restarted runner or a second
1374    /// document tab counts from one again.
1375    fn identity(&self) -> String {
1376        let mut identity = format!("{}#{:016x};", self.name, self.fingerprint);
1377        for pose in &self.poses {
1378            identity.push_str(&pose.identity());
1379        }
1380        identity
1381    }
1382}
1383
1384/// The exact solids a saved view draws, as a PLAN: which B-rep each is and how
1385/// it is posed, without posing anything — a projection whose lines are cached,
1386/// or computed on the runner, never needs the posed copies on this side.
1387struct ExactPlan<'a> {
1388    solids: Vec<LinesSolid>,
1389    /// The main side's copy of each, unposed.
1390    sources: Vec<&'a brep_kernel::BrepSolid>,
1391    /// Each solid's identity, and all of them together.
1392    each: Vec<String>,
1393    identity: String,
1394}
1395
1396impl ExactPlan<'_> {
1397    /// The B-rep of every solid, POSED by the view's own explode rows.
1398    ///
1399    /// The pose is the view's, not the viewport's: the display meshes are posed
1400    /// only while a view is ACTIVE, and a sheet draws every placement's view at
1401    /// once. A pose is affine, and a NURBS carrier is invariant under an affine
1402    /// map of its control points, so the posed solid is exact.
1403    fn posed(&self) -> Vec<std::borrow::Cow<'_, brep_kernel::BrepSolid>> {
1404        self.solids
1405            .iter()
1406            .zip(&self.sources)
1407            .map(|(solid, source)| posed_solid(std::borrow::Cow::Borrowed(*source), &solid.poses))
1408            .collect()
1409    }
1410}
1411
1412fn posed_solid<'a>(
1413    solid: std::borrow::Cow<'a, brep_kernel::BrepSolid>,
1414    poses: &[SolidPose],
1415) -> std::borrow::Cow<'a, brep_kernel::BrepSolid> {
1416    poses.iter().fold(solid, |solid, pose| std::borrow::Cow::Owned(pose_solid(&solid, &|p| pose.apply(p))))
1417}
1418
1419/// The plan of every solid a saved view draws, or `None` when any of them has
1420/// no topology in the scene.
1421fn exact_solids<'a>(scene: &'a RenderScene, view: &PmiView, report: Option<&PmiViewReport>) -> Option<ExactPlan<'a>> {
1422    let mut plan = ExactPlan { solids: Vec::new(), sources: Vec::new(), each: Vec::new(), identity: String::new() };
1423    for solid in drawn_solids(scene, view) {
1424        let (brep, fingerprint) = scene.exact_solid_fingerprinted(&solid.name)?;
1425        let planned = LinesSolid {
1426            name: solid.name.clone(),
1427            handle: solid.source_handle,
1428            fingerprint,
1429            poses: poses_of(report, &solid.name),
1430        };
1431        plan.each.push(planned.identity());
1432        plan.solids.push(planned);
1433        plan.sources.push(brep);
1434    }
1435    plan.identity = plan.each.concat();
1436    Some(plan)
1437}
1438
1439// ---------------------------------------------------------------------------
1440// The exact lines, and the job that computes them off the UI thread
1441// ---------------------------------------------------------------------------
1442
1443/// What an exact pass is restricted to, as a job carries it: a SECTION's cap
1444/// is the cut the job computes itself, so it has no data to carry.
1445#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1446pub enum LinesClip {
1447    Whole,
1448    Circle { radius: f64 },
1449    Section,
1450}
1451
1452impl LinesClip {
1453    /// The clip's part of an exact-lines key.
1454    fn key(&self) -> String {
1455        match self {
1456            LinesClip::Whole => "whole".into(),
1457            LinesClip::Circle { radius } => format!("circle {:x}", radius.to_bits()),
1458            LinesClip::Section => "section".into(),
1459        }
1460    }
1461}
1462
1463/// ONE placement's analytic pass, as the runner is asked to run it: everything
1464/// [`exact_lines`] would compute on a miss, by reference to the runner's own
1465/// resident solids rather than a copy of them.
1466#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1467pub struct LinesJob {
1468    /// The exact-lines key the answer is cached under.
1469    pub key: String,
1470    /// The placement it draws: a later job for the same placement SUPERSEDES
1471    /// this one (the engine drops a superseded answer).
1472    pub placement: String,
1473    pub solids: Vec<LinesSolid>,
1474    pub frame: ViewFrame,
1475    /// The placement at the paper origin — the lines are relative to it.
1476    pub placed: PlacedView,
1477    pub clip: LinesClip,
1478}
1479
1480/// What a projection does on an exact-lines MISS.
1481pub enum LinesMode<'a> {
1482    /// Run the pass here and now, on this thread — an export, a test, a runner
1483    /// that cannot run the job.
1484    Compute,
1485    /// Hand the job over. The answer comes back as the lines (a runner that
1486    /// answered at once), `None` while it is being computed elsewhere — the
1487    /// placement then draws its mesh approximation — or [`Deferred::Here`] to
1488    /// run it on this thread after all.
1489    Defer(&'a mut dyn FnMut(LinesJob) -> Deferred),
1490}
1491
1492/// A deferred job's answer.
1493pub enum Deferred {
1494    Lines(ExactLines),
1495    Pending,
1496    Here,
1497}
1498
1499/// Run `job` against the resident registry of THIS thread — the runner's side
1500/// of [`LinesMode::Defer`]. Refused when the registry no longer holds a handle,
1501/// or holds different geometry under it than the main side fingerprinted (an
1502/// assembly solve re-poses a resident solid in place): lines of the wrong
1503/// geometry cached under this key would be a silent error.
1504pub fn run_lines_job(job: &LinesJob) -> Result<ExactLines, String> {
1505    let mut posed = Vec::with_capacity(job.solids.len());
1506    for solid in &job.solids {
1507        let brep = brep_kernel::registered_solid_clone(solid.handle)
1508            .map_err(|error| format!("'{}' (handle {}): {error}", solid.name, solid.handle))?;
1509        if crate::scene::brep_fingerprint(&brep) != solid.fingerprint {
1510            return Err(format!("'{}' changed under handle {}", solid.name, solid.handle));
1511        }
1512        posed.push(posed_solid(std::borrow::Cow::Owned(brep), &solid.poses).into_owned());
1513    }
1514    let solids: Vec<&brep_kernel::BrepSolid> = posed.iter().collect();
1515    let each: Vec<String> = job.solids.iter().map(LinesSolid::identity).collect();
1516    Ok(compute_exact_lines(&solids, &each, &job.frame, &job.placed, job.clip))
1517}
1518
1519/// The analytic pass itself, relative to `origin` (a placement at the paper
1520/// origin): the visible runs and, for a section, the cut curves.
1521fn compute_exact_lines(
1522    solids: &[&brep_kernel::BrepSolid],
1523    each: &[String],
1524    frame: &ViewFrame,
1525    origin: &PlacedView,
1526    clip: LinesClip,
1527) -> ExactLines {
1528    let trims = solid_trims(each, solids);
1529    let trims: Vec<&super::hlr::SolidTrims> = trims.iter().map(|t| t.as_ref()).collect();
1530    match clip {
1531        LinesClip::Whole => ExactLines {
1532            runs: super::hlr::visible_edges_with(solids, &trims, frame, origin, &super::hlr::Clip::Whole).runs,
1533            cut: Vec::new(),
1534        },
1535        LinesClip::Circle { radius } => ExactLines {
1536            runs: super::hlr::visible_edges_with(solids, &trims, frame, origin, &super::hlr::Clip::Circle { radius }).runs,
1537            cut: Vec::new(),
1538        },
1539        LinesClip::Section => {
1540            // The exact cut: the plane against every face, trimmed. Its curves
1541            // are the hatch's boundary, the cap that hides what is behind the
1542            // cut face, and model lines in their own right.
1543            let cut = super::hlr::section_curves_with(solids, &trims, frame, origin);
1544            let clip = super::hlr::Clip::Beyond { cap: &cut };
1545            ExactLines { runs: super::hlr::visible_edges_with(solids, &trims, frame, origin, &clip).runs, cut }
1546        }
1547    }
1548}
1549
1550/// A placement's exact lines ON the paper at its position: from the cache,
1551/// else as `mode` says. `None` when the job was handed over and has not come
1552/// back — the caller draws the mesh approximation.
1553fn exact_lines_for(
1554    plan: &ExactPlan,
1555    frame: &ViewFrame,
1556    placed: &PlacedView,
1557    clip: LinesClip,
1558    mode: &mut LinesMode,
1559) -> Option<ExactLines> {
1560    let key = exact_key(&plan.identity, frame, placed.scale, &clip.key());
1561    let origin = PlacedView { position: [0.0, 0.0], ..placed.clone() };
1562    let relative = match cached_exact_lines(&key) {
1563        Some(lines) => lines,
1564        None => {
1565            let deferred = match mode {
1566                LinesMode::Compute => Deferred::Here,
1567                LinesMode::Defer(defer) => defer(LinesJob {
1568                    key: key.clone(),
1569                    placement: placed.id.clone(),
1570                    solids: plan.solids.clone(),
1571                    frame: *frame,
1572                    placed: origin.clone(),
1573                    clip,
1574                }),
1575            };
1576            let lines = match deferred {
1577                Deferred::Lines(lines) => lines,
1578                Deferred::Pending => return None,
1579                Deferred::Here => {
1580                    let posed = plan.posed();
1581                    let solids: Vec<&brep_kernel::BrepSolid> = posed.iter().map(|solid| solid.as_ref()).collect();
1582                    compute_exact_lines(&solids, &plan.each, frame, &origin, clip)
1583                }
1584            };
1585            store_exact_lines(key, lines.clone());
1586            lines
1587        }
1588    };
1589    let shift = |lines: Vec<Vec<[f64; 2]>>| -> Vec<Vec<[f64; 2]>> {
1590        lines
1591            .into_iter()
1592            .map(|line| line.into_iter().map(|p| [p[0] + placed.position[0], p[1] + placed.position[1]]).collect())
1593            .collect()
1594    };
1595    Some(ExactLines { runs: shift(relative.runs), cut: shift(relative.cut) })
1596}
1597
1598/// How many solids' trims the projection keeps: one for every placement the
1599/// exact-lines cache keeps.
1600const TRIM_CACHE_SOLIDS: usize = EXACT_CACHE_ENTRIES;
1601
1602thread_local! {
1603    /// Each drawn solid's [`super::hlr::SolidTrims`] by its identity (content
1604    /// fingerprint and pose): what the pass learns about a trim holds for any
1605    /// camera, scale or clip, so a new scale, a section or a detail of a part
1606    /// already drawn builds none of it again. Most recent last.
1607    static TRIM_CACHE: std::cell::RefCell<std::collections::VecDeque<(String, std::rc::Rc<super::hlr::SolidTrims>)>> =
1608        const { std::cell::RefCell::new(std::collections::VecDeque::new()) };
1609}
1610
1611
1612
1613/// The cached trims of each solid, built for any the cache does not hold.
1614fn solid_trims(identities: &[String], solids: &[&brep_kernel::BrepSolid]) -> Vec<std::rc::Rc<super::hlr::SolidTrims>> {
1615    TRIM_CACHE.with(|cache| {
1616        let mut cache = cache.borrow_mut();
1617        identities
1618            .iter()
1619            .zip(solids)
1620            .map(|(identity, solid)| {
1621                let trims = match cache.iter().position(|(key, _)| key == identity) {
1622                    Some(index) => cache.remove(index).map(|(_, trims)| trims).unwrap(),
1623                    None => std::rc::Rc::new(super::hlr::SolidTrims::of(solid)),
1624                };
1625                cache.push_back((identity.clone(), trims.clone()));
1626                while cache.len() > TRIM_CACHE_SOLIDS {
1627                    cache.pop_front();
1628                }
1629                trims
1630            })
1631            .collect()
1632    })
1633}
1634
1635/// The analytic pass's lines for one placement, RELATIVE to its position
1636/// (paper millimetres from the placement point): the visible runs and, for a
1637/// section, the cut curves. What a [`LinesJob`] answers with.
1638#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1639pub struct ExactLines {
1640    pub runs: Vec<Vec<[f64; 2]>>,
1641    pub cut: Vec<Vec<[f64; 2]>>,
1642}
1643
1644/// How many placements' exact lines are kept.
1645const EXACT_CACHE_ENTRIES: usize = 64;
1646
1647thread_local! {
1648    /// The exact lines by what they depend on — the geometry's identity, the
1649    /// camera frame, the scale and the clip, never the placement's POSITION —
1650    /// so dragging a placement, dragging a PMI label or editing a dimension
1651    /// re-projects the sheet without re-running the pass. Most recent last.
1652    /// On the UI thread it is also where a job's answer lands
1653    /// ([`store_exact_lines`]).
1654    static EXACT_CACHE: std::cell::RefCell<std::collections::VecDeque<(String, ExactLines)>> =
1655        const { std::cell::RefCell::new(std::collections::VecDeque::new()) };
1656}
1657
1658/// The cached exact lines for `key`, relative to the placement point.
1659pub(crate) fn cached_exact_lines(key: &str) -> Option<ExactLines> {
1660    EXACT_CACHE.with(|cache| cache.borrow().iter().find(|(k, _)| k == key).map(|(_, lines)| lines.clone()))
1661}
1662
1663/// Keep `lines` under `key`, most recent last — a pass run here, or a job's
1664/// answer from the runner.
1665pub(crate) fn store_exact_lines(key: String, lines: ExactLines) {
1666    EXACT_CACHE.with(|cache| {
1667        let mut cache = cache.borrow_mut();
1668        cache.retain(|(k, _)| *k != key);
1669        cache.push_back((key, lines));
1670        while cache.len() > EXACT_CACHE_ENTRIES {
1671            cache.pop_front();
1672        }
1673    });
1674}
1675
1676/// The part of an exact-lines key a placement's frame, scale and clip give.
1677fn exact_key(identity: &str, frame: &ViewFrame, scale: f64, clip: &str) -> String {
1678    let bits = |v: [f64; 3]| format!("{:x},{:x},{:x}", v[0].to_bits(), v[1].to_bits(), v[2].to_bits());
1679    format!(
1680        "{identity}|{}|{}|{}|{}|{:x}|{clip}",
1681        bits(frame.target),
1682        bits(frame.right),
1683        bits(frame.up),
1684        bits(frame.forward),
1685        scale.to_bits()
1686    )
1687}
1688
1689/// `solid` with every model point mapped through the affine `pose`: vertices,
1690/// edge curves' and carriers' control points (a rational control point is
1691/// mapped as its Euclidean point and re-weighted). Carriers are REBUILT
1692/// rather than cloned, so no analytic recognition cached on the unposed
1693/// surface survives onto the posed one. Pcurves live in parameter space and
1694/// are unchanged.
1695fn pose_solid(solid: &brep_kernel::BrepSolid, pose: &dyn Fn([f64; 3]) -> [f64; 3]) -> brep_kernel::BrepSolid {
1696    let map = |cp: &brep_kernel::Vec4| -> brep_kernel::Vec4 {
1697        let w = if cp.w != 0.0 { cp.w } else { 1.0 };
1698        let p = pose([cp.x / w, cp.y / w, cp.z / w]);
1699        brep_kernel::Vec4 { x: p[0] * w, y: p[1] * w, z: p[2] * w, w: cp.w }
1700    };
1701    let mut out = solid.clone();
1702    for vertex in &mut out.vertices {
1703        let p = pose([vertex.point.x, vertex.point.y, vertex.point.z]);
1704        vertex.point = brep_kernel::Vec3::new(p[0], p[1], p[2]);
1705    }
1706    for edge in &mut out.edges {
1707        let points = edge.curve.control_points.iter().map(map).collect();
1708        if let Ok(curve) = brep_kernel::NurbsCurve::new(edge.curve.degree, edge.curve.knots.clone(), points) {
1709            edge.curve = curve;
1710        }
1711    }
1712    for shell in &mut out.shells {
1713        for face in &mut shell.faces {
1714            let s = &face.surface;
1715            let points = s.control_points.iter().map(|row| row.iter().map(map).collect()).collect();
1716            if let Ok(surface) =
1717                brep_kernel::NurbsSurface::new(s.degree_u, s.degree_v, s.knots_u.clone(), s.knots_v.clone(), points)
1718            {
1719                face.surface = surface;
1720            }
1721        }
1722    }
1723    out
1724}
1725
1726/// Polylines as the two-point segments [`hatch`] counts crossings against.
1727fn segments_of(lines: &[Vec<[f64; 2]>]) -> Vec<Vec<[f64; 2]>> {
1728    lines.iter().flat_map(|line| line.windows(2).map(|w| vec![w[0], w[1]])).collect()
1729}
1730
1731// ---------------------------------------------------------------------------
1732// The drawn meshes
1733// ---------------------------------------------------------------------------
1734
1735/// The solids a saved view draws: visible in the scene, not hidden by the
1736/// view's own `display.hidden` set, and not a committed SKETCH (a drawing
1737/// shows the model, not the sketches it was built from).
1738fn drawn_solids<'a>(scene: &'a RenderScene, view: &PmiView) -> Vec<&'a crate::scene::SolidDisplay> {
1739    scene
1740        .solids()
1741        .iter()
1742        .filter(|solid| solid.visible && !solid.is_sketch)
1743        .filter(|solid| !view.display.hidden.iter().any(|name| name == &solid.name))
1744        .filter(|solid| solid.mesh.indices.len() >= 3)
1745        .collect()
1746}
1747
1748/// The names of every solid some placement of `sheet` draws: the solids whose
1749/// topology its exact pass needs.
1750pub fn sheet_solid_names(scene: &RenderScene, sheet: &super::Sheet, views: &[PmiView]) -> Vec<String> {
1751    let mut names: Vec<String> = Vec::new();
1752    for placed in &sheet.views {
1753        let Some(view) = views.iter().find(|view| view.id == placed.view) else { continue };
1754        for solid in drawn_solids(scene, view) {
1755            if !names.contains(&solid.name) {
1756                names.push(solid.name.clone());
1757            }
1758        }
1759    }
1760    names
1761}
1762
1763/// The DISPLAY each drawn solid is read from: the un-posed one where the
1764/// viewport's active view posed it in place, so every placement starts from
1765/// the model as built and applies its own view's poses.
1766fn drawn_displays<'a>(scene: &'a RenderScene, unposed: &'a Unposed, view: &PmiView) -> Vec<&'a crate::scene::SolidDisplay> {
1767    drawn_solids(scene, view).into_iter().map(|solid| unposed.get(&solid.name).unwrap_or(solid)).collect()
1768}
1769
1770
1771/// Every drawn solid's DISPLAY as the mesh approximation reads it
1772/// ([`super::hlr::visible_mesh_lines`]), POSED by the view's own explode rows
1773/// exactly as the exact pass poses the B-rep: the triangles with their faces,
1774/// and every display edge that is a boundary of the model — not an auxiliary
1775/// or centreline edge — with the two topology VERTICES it runs between.
1776///
1777/// **The ends are the kernel's adjacency, recovered exactly.** A display edge
1778/// carries no vertex ids, but the tessellator writes an edge's first and last
1779/// samples AS its start and end vertex's own points
1780/// (`watertight_tessellation::edge_sampling`), and the display narrows both to
1781/// `f32` the same way. So an end whose `f32` bits are one display vertex's is
1782/// that vertex, with no distance and no bar; an end shared by two vertices
1783/// narrowed to one `f32` point is no vertex (nothing chains through it), and
1784/// so is an end that is none. The ends are read BEFORE the pose, off the
1785/// display's own numbers.
1786fn mesh_solids(displays: &[&crate::scene::SolidDisplay], report: Option<&PmiViewReport>) -> Vec<super::hlr::MeshSolid> {
1787    let world = |p: &[f32; 3]| [p[0] as f64, p[1] as f64, p[2] as f64];
1788    displays
1789        .iter()
1790        .map(|solid| {
1791            let poses = poses_of(report, &solid.name);
1792            let pose = |p: [f64; 3]| poses.iter().fold(p, |p, pose| pose.apply(p));
1793            let count = solid.mesh.positions.len();
1794            let (triangles, faces) = solid
1795                .mesh
1796                .indices
1797                .chunks_exact(3)
1798                .enumerate()
1799                .filter(|(_, c)| c.iter().all(|i| (*i as usize) < count))
1800                .map(|(t, c)| ([c[0], c[1], c[2]], solid.mesh.face_ids.get(t).copied().unwrap_or(u32::MAX)))
1801                .unzip();
1802            let bits = |p: [f32; 3]| [p[0].to_bits(), p[1].to_bits(), p[2].to_bits()];
1803            let mut at: std::collections::HashMap<[u32; 3], Option<u64>> = std::collections::HashMap::new();
1804            for vertex in &solid.vertices {
1805                let p = vertex.position;
1806                at.entry(bits([p[0] as f32, p[1] as f32, p[2] as f32]))
1807                    .and_modify(|id| *id = None)
1808                    .or_insert(Some(vertex.topo_id));
1809            }
1810            let vertex_of = |p: &[f32; 3]| at.get(&bits(*p)).copied().flatten();
1811            super::hlr::MeshSolid {
1812                positions: solid.mesh.positions.iter().map(|p| pose(world(p))).collect(),
1813                triangles,
1814                faces,
1815                edges: solid
1816                    .edges
1817                    .iter()
1818                    .filter(|edge| !edge.aux && !edge.centerline && edge.polyline.len() >= 2)
1819                    .map(|edge| super::hlr::MeshEdge {
1820                        points: edge.polyline.iter().map(|p| pose(world(p))).collect(),
1821                        ends: vertex_of(&edge.polyline[0]).zip(vertex_of(edge.polyline.last().unwrap())),
1822                    })
1823                    .collect(),
1824            }
1825        })
1826        .collect()
1827}
1828
1829/// How many placements' mesh approximations are kept.
1830const MESH_CACHE_ENTRIES: usize = 16;
1831
1832thread_local! {
1833    /// The mesh approximation's lines by what they depend on — each drawn
1834    /// display's content revision and poses, the camera frame, the scale, the
1835    /// clip and a section's plane — never the placement's position, exactly as
1836    /// the exact lines are kept. A placement waiting for its exact pass is
1837    /// re-projected by every edit on the sheet, and the approximation of a
1838    /// large part is tens of milliseconds a time. Most recent last.
1839    static MESH_CACHE: std::cell::RefCell<std::collections::VecDeque<(String, ExactLines)>> =
1840        const { std::cell::RefCell::new(std::collections::VecDeque::new()) };
1841}
1842
1843/// A placement's mesh approximation ON the paper at its position: the runs
1844/// and, for a section, the cut through the display triangles.
1845#[allow(clippy::too_many_arguments)]
1846fn mesh_lines_for(
1847    scene: &RenderScene,
1848    unposed: &Unposed,
1849    view: &PmiView,
1850    report: Option<&PmiViewReport>,
1851    frame: &ViewFrame,
1852    placed: &PlacedView,
1853    clip: LinesClip,
1854    plane: Option<&ClipPlane>,
1855) -> ExactLines {
1856    let displays = drawn_displays(scene, unposed, view);
1857    let mut identity = String::from("mesh|");
1858    for display in &displays {
1859        identity.push_str(&format!("{}@{};", display.name, display.revision));
1860        for pose in poses_of(report, &display.name) {
1861            identity.push_str(&pose.identity());
1862        }
1863    }
1864    let bits = |v: [f64; 3]| format!("{:x},{:x},{:x}", v[0].to_bits(), v[1].to_bits(), v[2].to_bits());
1865    let plane_key = plane.map(|plane| format!("{}|{}", bits(plane.point), bits(plane.normal))).unwrap_or_default();
1866    let key = exact_key(&identity, frame, placed.scale, &format!("{}{plane_key}", clip.key()));
1867    let hit = MESH_CACHE.with(|cache| cache.borrow().iter().find(|(k, _)| *k == key).map(|(_, lines)| lines.clone()));
1868    let relative = match hit {
1869        Some(lines) => lines,
1870        None => {
1871            let origin = PlacedView { position: [0.0, 0.0], ..placed.clone() };
1872            let meshes = mesh_solids(&displays, report);
1873            let lines = match (clip, plane) {
1874                (LinesClip::Section, Some(plane)) => {
1875                    let cut: Vec<Vec<[f64; 2]>> = mesh_cut(&meshes, plane)
1876                        .iter()
1877                        .map(|segment| {
1878                            segment
1879                                .iter()
1880                                .map(|p| {
1881                                    let v = frame.to_view(*p);
1882                                    to_paper(&origin, v[0], v[1])
1883                                })
1884                                .collect()
1885                        })
1886                        .collect();
1887                    let beyond = super::hlr::Clip::Beyond { cap: &cut };
1888                    ExactLines { runs: super::hlr::visible_mesh_lines(&meshes, frame, &origin, &beyond).runs, cut }
1889                }
1890                (LinesClip::Circle { radius }, _) => ExactLines {
1891                    runs: super::hlr::visible_mesh_lines(&meshes, frame, &origin, &super::hlr::Clip::Circle { radius }).runs,
1892                    cut: Vec::new(),
1893                },
1894                _ => ExactLines {
1895                    runs: super::hlr::visible_mesh_lines(&meshes, frame, &origin, &super::hlr::Clip::Whole).runs,
1896                    cut: Vec::new(),
1897                },
1898            };
1899            MESH_CACHE.with(|cache| {
1900                let mut cache = cache.borrow_mut();
1901                cache.push_back((key, lines.clone()));
1902                while cache.len() > MESH_CACHE_ENTRIES {
1903                    cache.pop_front();
1904                }
1905            });
1906            lines
1907        }
1908    };
1909    let shift = |lines: Vec<Vec<[f64; 2]>>| -> Vec<Vec<[f64; 2]>> {
1910        lines
1911            .into_iter()
1912            .map(|line| line.into_iter().map(|p| [p[0] + placed.position[0], p[1] + placed.position[1]]).collect())
1913            .collect()
1914    };
1915    ExactLines { runs: shift(relative.runs), cut: shift(relative.cut) }
1916}
1917
1918// ---------------------------------------------------------------------------
1919// The cutting plane
1920// ---------------------------------------------------------------------------
1921
1922/// The half-space a SECTION placement draws: everything with
1923/// `(p \u{2212} point) \u{00B7} normal >= 0` is KEPT, and `normal` is the direction the
1924/// section camera looks.
1925///
1926/// Because the camera looks ALONG the normal, the cutting plane is a plane of
1927/// CONSTANT view depth — and the plane passes through the camera's target, so
1928/// that depth is exactly zero. Everything that follows leans on it: the pass
1929/// keeps what is at or beyond depth zero, the cut region is a cap at one depth,
1930/// and the hatch is a plain two-dimensional fill. A section view is defined as
1931/// the one that looks straight down its own cutting plane's normal.
1932#[derive(Debug, Clone, Copy)]
1933pub struct ClipPlane {
1934    pub point: [f64; 3],
1935    pub normal: [f64; 3],
1936}
1937
1938/// The mesh approximation's cut: the segment where the plane crosses each
1939/// display triangle that keeps area on the kept side — the section's outline
1940/// and the region its hatch fills and its cap hides behind, while the exact
1941/// cut ([`super::hlr::section_curves`]) waits for the B-rep.
1942fn mesh_cut(meshes: &[super::hlr::MeshSolid], plane: &ClipPlane) -> Vec<[[f64; 3]; 2]> {
1943    // The on-plane bar, relative to the model's own extent: a vertex this
1944    // close to the plane IS on it, and treating it as one side or the other
1945    // would open the cut boundary by one triangle.
1946    let mut extent: f64 = 0.0;
1947    for mesh in meshes {
1948        for p in &mesh.positions {
1949            extent = extent.max(dot(sub(*p, plane.point), plane.normal).abs());
1950        }
1951    }
1952    let eps = (extent * 1e-9).max(f64::MIN_POSITIVE);
1953
1954    let mut cut: Vec<[[f64; 3]; 2]> = Vec::new();
1955    for mesh in meshes {
1956        let side: Vec<f64> = mesh
1957            .positions
1958            .iter()
1959            .map(|p| dot(sub(*p, plane.point), plane.normal))
1960            .collect();
1961        for tri in &mesh.triangles {
1962            let corners = [tri[0] as usize, tri[1] as usize, tri[2] as usize];
1963            let d = [side[corners[0]], side[corners[1]], side[corners[2]]];
1964            if d.iter().all(|v| *v < -eps) || d.iter().all(|v| *v > eps) {
1965                continue; // wholly cut away, or wholly kept
1966            }
1967            // Straddling (or touching): walk the edges, counting what is on or
1968            // in front of the plane and collecting the crossing points.
1969            let mut kept = 0;
1970            let mut on_plane: Vec<[f64; 3]> = Vec::with_capacity(2);
1971            for k in 0..3 {
1972                let (i, j) = (k, (k + 1) % 3);
1973                let (pi, pj) = (mesh.positions[corners[i]], mesh.positions[corners[j]]);
1974                let (di, dj) = (d[i], d[j]);
1975                if di >= -eps {
1976                    kept += 1;
1977                    if di.abs() <= eps {
1978                        on_plane.push(pi);
1979                    }
1980                }
1981                if (di > eps && dj < -eps) || (di < -eps && dj > eps) {
1982                    let t = di / (di - dj);
1983                    kept += 1;
1984                    on_plane.push([
1985                        pi[0] + (pj[0] - pi[0]) * t,
1986                        pi[1] + (pj[1] - pi[1]) * t,
1987                        pi[2] + (pj[2] - pi[2]) * t,
1988                    ]);
1989                }
1990            }
1991            // A cut edge belongs to the triangle that KEPT AREA on it, and to
1992            // exactly that one. Two on-plane points is the edge; three is a
1993            // triangle lying IN the plane, whose own edges already bound the
1994            // region, and one is a triangle touching the plane at a corner.
1995            //
1996            // The kept-area half is not a nicety. A cutting plane through a
1997            // mesh VERTEX RING — which is what a plane through the middle of a
1998            // tessellated cylinder is, and hardly a rare accident — gives the
1999            // ring's edge to two triangles at once: the one above it, which
2000            // keeps a real triangle, and the one below, which is cut away
2001            // entirely and keeps only that edge. Counting both would put the
2002            // boundary in twice, and an even-odd fill crossing a boundary
2003            // twice reads the inside as the outside.
2004            if kept >= 3 && on_plane.len() == 2 {
2005                cut.push([on_plane[0], on_plane[1]]);
2006            }
2007        }
2008    }
2009    cut
2010}
2011
2012/// The hatch over a cut region: parallel 45\u{00B0} paper lines, each kept only
2013/// where an EVEN-ODD crossing count against the cut boundary says it is inside
2014/// the material.
2015///
2016/// Even-odd against the SEGMENTS rather than against chained loops, on purpose:
2017/// the cut of a closed solid is a closed set of curves however the segments
2018/// happen to group, so the parity is right without a chaining pass that could
2019/// break a loop at a vertex three edges meet in — and a section through a bore
2020/// is exactly such a region, an outer loop with a hole in it.
2021fn hatch(cut: &[Vec<[f64; 2]>]) -> Vec<Vec<[f64; 2]>> {
2022    if cut.is_empty() {
2023        return Vec::new();
2024    }
2025    // The hatch runs up and to the right; `n` is its normal, so a line of the
2026    // family is `p \u{00B7} n = c` and a point's place ALONG it is `p \u{00B7} u`.
2027    let d = std::f64::consts::FRAC_1_SQRT_2;
2028    let u = [d, -d];
2029    let n = [d, d];
2030    let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
2031    for segment in cut {
2032        for p in segment {
2033            let c = p[0] * n[0] + p[1] * n[1];
2034            lo = lo.min(c);
2035            hi = hi.max(c);
2036        }
2037    }
2038    if !lo.is_finite() || hi <= lo {
2039        return Vec::new();
2040    }
2041    let mut spacing = HATCH_SPACING_MM;
2042    while (hi - lo) / spacing > MAX_HATCH_LINES as f64 {
2043        spacing *= 2.0;
2044    }
2045    // Start on a multiple of the spacing, so two sections of the same drawing
2046    // hatch in step rather than each from its own corner.
2047    let first = (lo / spacing).ceil() * spacing;
2048    let mut out = Vec::new();
2049    let mut c = first;
2050    while c <= hi {
2051        let mut crossings: Vec<f64> = Vec::new();
2052        for segment in cut {
2053            let (a, b) = (segment[0], segment[segment.len() - 1]);
2054            let (ca, cb) = (a[0] * n[0] + a[1] * n[1], b[0] * n[0] + b[1] * n[1]);
2055            // Half-open in `c`, so a vertex two segments share is counted once
2056            // and the parity survives it.
2057            if (ca <= c) == (cb <= c) {
2058                continue;
2059            }
2060            let t = (c - ca) / (cb - ca);
2061            let hit = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
2062            crossings.push(hit[0] * u[0] + hit[1] * u[1]);
2063        }
2064        crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2065        for pair in crossings.chunks_exact(2) {
2066            if pair[1] - pair[0] < 1e-9 {
2067                continue;
2068            }
2069            out.push(vec![
2070                [u[0] * pair[0] + n[0] * c, u[1] * pair[0] + n[1] * c],
2071                [u[0] * pair[1] + n[0] * c, u[1] * pair[1] + n[1] * c],
2072            ]);
2073        }
2074        c += spacing;
2075    }
2076    out
2077}
2078
2079// ---------------------------------------------------------------------------
2080// Anchor candidates
2081// ---------------------------------------------------------------------------
2082
2083/// The angular span a circular edge must cover before it is offered as a
2084/// CIRCLE anchor. Three samples fix a circle exactly, but three samples a
2085/// degree apart fix it badly: a short fillet rim would hand a radial dimension
2086/// a centre with no precision behind it.
2087const CIRCLE_MIN_SPAN_DEG: f64 = 20.0;
2088
2089/// How far a sampled point may sit off the fitted circle, as a fraction of the
2090/// radius, before the edge is not a circle. The display polyline is SAMPLED on
2091/// the curve, so a real circular edge misses by f32 rounding alone; anything
2092/// looser than this is a spline that happens to start out round.
2093const CIRCLE_FIT_TOLERANCE: f64 = 1e-4;
2094
2095/// How far a sampled point may sit off the CHORD, as a fraction of the chord's
2096/// length, before the display edge is not STRAIGHT. The same bar as the circle
2097/// fit and for the same reason: the polyline is sampled on the curve, so a real
2098/// straight edge misses its own chord by f32 rounding alone.
2099const STRAIGHT_TOLERANCE: f64 = 1e-4;
2100
2101/// Every point of this placement's projection a sheet dimension can anchor to.
2102///
2103/// One candidate per display VERTEX, three per named display EDGE (its two
2104/// ends and its midpoint by arc length) and one more per CIRCULAR edge, at the
2105/// circle's projected centre. Visibility plays no part: the hidden-line pass
2106/// decides what ink the model leaves on the paper, and the anchor space is the
2107/// model's topology.
2108fn anchor_points(
2109    scene: &RenderScene,
2110    unposed: &Unposed,
2111    view: &PmiView,
2112    report: Option<&PmiViewReport>,
2113    frame: &ViewFrame,
2114    placed: &PlacedView,
2115) -> Vec<AnchorPoint> {
2116    let project = |p: [f64; 3]| -> [f64; 2] {
2117        let v = frame.to_view(p);
2118        to_paper(placed, v[0], v[1])
2119    };
2120    let mut out = Vec::new();
2121    for solid in drawn_displays(scene, unposed, view) {
2122        // The anchors are where the placement DRAWS the model: posed by its
2123        // own view's explode rows, as the lines are.
2124        let poses = poses_of(report, &solid.name);
2125        let pose = |p: [f64; 3]| poses.iter().fold(p, |p, pose| pose.apply(p));
2126        for vertex in &solid.vertices {
2127            out.push(AnchorPoint {
2128                anchor: super::dimension::SheetAnchor::vertex(&placed.id, &solid.name, vertex.topo_id)
2129                    .to_ref(),
2130                kind: super::dimension::VERTEX.to_string(),
2131                at: project(pose(vertex.position)),
2132                model: pose(vertex.position),
2133                dir: None,
2134                radius: None,
2135                u: None,
2136                v: None,
2137            });
2138        }
2139        for edge in &solid.edges {
2140            // An edge with no name cannot be REFERRED to, and an auxiliary or
2141            // centreline display edge is not a BREP boundary — neither is a
2142            // point of the model a drawing may dimension.
2143            if edge.name.is_empty() || edge.aux || edge.centerline || edge.polyline.len() < 2 {
2144                continue;
2145            }
2146            let points: Vec<[f64; 3]> = edge
2147                .polyline
2148                .iter()
2149                .map(|p| pose([p[0] as f64, p[1] as f64, p[2] as f64]))
2150                .collect();
2151            // The EDGE's own paper direction, once — every fraction of it
2152            // carries the same one, because it is the edge's and not the
2153            // point's. `None` on a curved edge, which an angular dimension
2154            // refuses by name.
2155            let dir = straight_chord(&points).and_then(|d| {
2156                let paper = [dot(d, frame.right), -dot(d, frame.up)];
2157                let length = (paper[0] * paper[0] + paper[1] * paper[1]).sqrt();
2158                (length > 1e-9).then(|| [paper[0] / length, paper[1] / length])
2159            });
2160            for fraction in [0.0, 0.5, 1.0] {
2161                let Some(point) = along(&points, fraction) else { continue };
2162                out.push(AnchorPoint {
2163                    anchor: super::dimension::SheetAnchor::edge(
2164                        &placed.id,
2165                        &solid.name,
2166                        &edge.name,
2167                        fraction,
2168                    )
2169                    .to_ref(),
2170                    kind: super::dimension::EDGE.to_string(),
2171                    at: project(point),
2172                    model: point,
2173                    dir,
2174                    radius: None,
2175                    u: None,
2176                    v: None,
2177                });
2178            }
2179            if let Some((centre, axis, radius)) = fit_circle(&points) {
2180                // The projected circle is an ellipse: `u` and `v` are the paper
2181                // images of one pair of conjugate radii, which is everything a
2182                // radial dimension needs to find where its leader meets the rim.
2183                let u3 = norm(sub(points[0], centre));
2184                let v3 = cross(axis, u3);
2185                let image = |d: [f64; 3]| -> [f64; 2] {
2186                    [
2187                        dot(d, frame.right) * radius * placed.scale,
2188                        -dot(d, frame.up) * radius * placed.scale,
2189                    ]
2190                };
2191                out.push(AnchorPoint {
2192                    anchor: super::dimension::SheetAnchor::circle(&placed.id, &solid.name, &edge.name)
2193                        .to_ref(),
2194                    kind: super::dimension::CIRCLE.to_string(),
2195                    at: project(centre),
2196                    model: centre,
2197                    dir: None,
2198                    radius: Some(radius),
2199                    u: Some(image(u3)),
2200                    v: Some(image(v3)),
2201                });
2202            }
2203        }
2204    }
2205    out
2206}
2207
2208/// The unit MODEL direction of a straight display edge, or `None` when the
2209/// polyline is curved or closed on itself.
2210///
2211/// Straight means every SAMPLE lies on the chord between the two ends, within
2212/// [`STRAIGHT_TOLERANCE`] of its length. Judged in the model rather than on the
2213/// paper on purpose: a circular edge seen edge-on projects to a straight
2214/// segment, and an angle measured to it would be an angle to nothing.
2215fn straight_chord(points: &[[f64; 3]]) -> Option<[f64; 3]> {
2216    let (first, last) = (*points.first()?, *points.last()?);
2217    let span = sub(last, first);
2218    let length = dot(span, span).sqrt();
2219    if length < 1e-12 {
2220        // A closed edge (a full circle) has no chord at all.
2221        return None;
2222    }
2223    let direction = [span[0] / length, span[1] / length, span[2] / length];
2224    let bar = length * STRAIGHT_TOLERANCE;
2225    for point in points {
2226        let offset = sub(*point, first);
2227        let along = dot(offset, direction);
2228        let away = [
2229            offset[0] - direction[0] * along,
2230            offset[1] - direction[1] * along,
2231            offset[2] - direction[2] * along,
2232        ];
2233        if dot(away, away).sqrt() > bar {
2234            return None;
2235        }
2236    }
2237    Some(direction)
2238}
2239
2240/// The point `fraction` of the way along a polyline BY ARC LENGTH.
2241fn along(points: &[[f64; 3]], fraction: f64) -> Option<[f64; 3]> {
2242    if points.len() < 2 {
2243        return None;
2244    }
2245    let steps: Vec<f64> = points
2246        .windows(2)
2247        .map(|pair| dot(sub(pair[1], pair[0]), sub(pair[1], pair[0])).sqrt())
2248        .collect();
2249    let total: f64 = steps.iter().sum();
2250    if !(total > 0.0) {
2251        return Some(points[0]);
2252    }
2253    let target = total * fraction.clamp(0.0, 1.0);
2254    let mut walked = 0.0;
2255    for (index, step) in steps.iter().enumerate() {
2256        if walked + step >= target || index + 1 == steps.len() {
2257            let t = if *step > 0.0 { ((target - walked) / step).clamp(0.0, 1.0) } else { 0.0 };
2258            let (a, b) = (points[index], points[index + 1]);
2259            return Some([
2260                a[0] + (b[0] - a[0]) * t,
2261                a[1] + (b[1] - a[1]) * t,
2262                a[2] + (b[2] - a[2]) * t,
2263            ]);
2264        }
2265        walked += step;
2266    }
2267    points.last().copied()
2268}
2269
2270/// `(centre, unit axis, radius)` when every sampled point of `points` lies on
2271/// one circle, else `None`.
2272///
2273/// The circle through three of the samples, then VERIFIED against all of them:
2274/// a display polyline is sampled ON its curve, so a real circular edge's
2275/// samples sit on that circle to f32 rounding while a spline's do not. The
2276/// axis follows the right-hand rule along the polyline's own direction, the
2277/// kernel's `SelectionGeometry::Circle` convention.
2278fn fit_circle(points: &[[f64; 3]]) -> Option<([f64; 3], [f64; 3], f64)> {
2279    if points.len() < 4 {
2280        return None;
2281    }
2282    let last = points.len() - 1;
2283    let (a, b, c) = (points[0], points[last / 3], points[last * 2 / 3]);
2284    let (ab, ac) = (sub(b, a), sub(c, a));
2285    let n = cross(ab, ac);
2286    let n2 = dot(n, n);
2287    if n2 < 1e-24 {
2288        return None;
2289    }
2290    // The circumcentre of a triangle, in the triangle's own plane.
2291    let centre = {
2292        let (ab2, ac2) = (dot(ab, ab), dot(ac, ac));
2293        let to = cross(
2294            [
2295                ab2 * ac[0] - ac2 * ab[0],
2296                ab2 * ac[1] - ac2 * ab[1],
2297                ab2 * ac[2] - ac2 * ab[2],
2298            ],
2299            n,
2300        );
2301        [
2302            a[0] + to[0] / (2.0 * n2),
2303            a[1] + to[1] / (2.0 * n2),
2304            a[2] + to[2] / (2.0 * n2),
2305        ]
2306    };
2307    let axis = norm(n);
2308    let radius = dot(sub(a, centre), sub(a, centre)).sqrt();
2309    if !(radius > 1e-12) {
2310        return None;
2311    }
2312    let bar = radius * CIRCLE_FIT_TOLERANCE;
2313    for p in points {
2314        let d = sub(*p, centre);
2315        if (dot(d, d).sqrt() - radius).abs() > bar || dot(d, axis).abs() > bar {
2316            return None;
2317        }
2318    }
2319    // A rim too short to fit reliably is not offered: three samples a degree
2320    // apart give a centre with no precision behind it.
2321    let first = norm(sub(points[0], centre));
2322    let mut span = 0.0;
2323    for p in points {
2324        let d = norm(sub(*p, centre));
2325        let angle = dot(d, first).clamp(-1.0, 1.0).acos();
2326        span = f64::max(span, angle);
2327    }
2328    if span.to_degrees() < CIRCLE_MIN_SPAN_DEG {
2329        return None;
2330    }
2331    Some((centre, axis, radius))
2332}
2333
2334// ---------------------------------------------------------------------------
2335// Annotations
2336// ---------------------------------------------------------------------------
2337
2338/// A blocking box in paper millimetres: `centre`, the paper images of one
2339/// LOCAL unit along the box's own two axes, and the half extents in those
2340/// units. The axes are a text run's own — under an oblique projection they
2341/// are sheared, so the box is a parallelogram and not a rotated rectangle.
2342#[derive(Debug, Clone, Copy)]
2343struct Box2 {
2344    centre: [f64; 2],
2345    right: [f64; 2],
2346    up: [f64; 2],
2347    half: [f64; 2],
2348}
2349
2350impl Box2 {
2351    /// `p` in the box's own units, or `None` when the basis is DEGENERATE —
2352    /// an edge-on plane projects its two axes onto one line, and a box with
2353    /// no area blocks nothing.
2354    fn local(&self, p: [f64; 2]) -> Option<[f64; 2]> {
2355        let det = self.right[0] * self.up[1] - self.right[1] * self.up[0];
2356        if det.abs() < 1e-12 {
2357            return None;
2358        }
2359        let d = [p[0] - self.centre[0], p[1] - self.centre[1]];
2360        Some([
2361            (d[0] * self.up[1] - d[1] * self.up[0]) / det,
2362            (d[1] * self.right[0] - d[0] * self.right[1]) / det,
2363        ])
2364    }
2365
2366    /// The box a text run occupies: its own basis, its character extent in
2367    /// cap heights, and [`TEXT_PAD`] of clearance on every side.
2368    fn of_text(run: &SheetText) -> Box2 {
2369        let rows = run.text.lines().count().max(1) as f64;
2370        Box2 {
2371            centre: run.anchor,
2372            right: run.right,
2373            up: run.up,
2374            half: [text_half_width(&run.text, 1.0), rows * 0.5 + TEXT_PAD],
2375        }
2376    }
2377}
2378
2379/// How far a FLAT run of `text` at cap height `height` blocks a line either
2380/// side of its anchor along its reading direction: its character extent at
2381/// [`TEXT_ADVANCE`] plus [`TEXT_PAD`] of clearance — the box
2382/// [`break_for_text_boxes`] breaks a line with.
2383pub(super) fn text_half_width(text: &str, height: f64) -> f64 {
2384    let columns = text.lines().map(|line| line.chars().count()).max().unwrap_or(0) as f64;
2385    (columns * TEXT_ADVANCE * 0.5 + TEXT_PAD) * height
2386}
2387
2388/// Break `lines` where a text run — or a framed symbol's outline — sits.
2389///
2390/// The kernel's layout runs the dimension line THROUGH the label — the
2391/// viewport hides that under the label's chip and the STEP presentation leaves
2392/// it — but a sheet has no chip and a value struck through by its own
2393/// dimension line is not a drawing. Breaking the line is a SHEET-layer
2394/// decision, so it is made here rather than in the shared layout: the gap is
2395/// the text's own box plus a clearance, subtracted from every line of the same
2396/// annotation. A framed symbol (an FCF, a datum) adds its FRAME as a box with
2397/// no clearance, which is what re-attaches its leader to the box's nearest
2398/// edge when the frame has been flattened out from under it. The frame's own
2399/// outline is never offered to this pass — it would erase itself.
2400fn break_for_text(lines: Vec<Vec<[f64; 2]>>, boxes: &[Box2]) -> Vec<Vec<[f64; 2]>> {
2401    if boxes.is_empty() {
2402        return lines;
2403    }
2404    let mut out: Vec<Vec<[f64; 2]>> = Vec::new();
2405    for line in lines {
2406        // Each run of consecutive UNBROKEN segments becomes one polyline.
2407        let mut current: Vec<[f64; 2]> = Vec::new();
2408        for pair in line.windows(2) {
2409            let (p, q) = (pair[0], pair[1]);
2410            let mut kept = visible_spans(p, q, boxes);
2411            if kept.is_empty() {
2412                if current.len() >= 2 {
2413                    out.push(std::mem::take(&mut current));
2414                } else {
2415                    current.clear();
2416                }
2417                continue;
2418            }
2419            let full = kept.len() == 1 && kept[0].0 <= 0.0 && kept[0].1 >= 1.0;
2420            let at = |t: f64| [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t];
2421            if full {
2422                if current.is_empty() {
2423                    current.push(p);
2424                }
2425                current.push(q);
2426                continue;
2427            }
2428            // A broken segment ends whatever was running and contributes its
2429            // own pieces.
2430            if !kept.is_empty() && kept[0].0 <= 0.0 {
2431                let (_, end) = kept.remove(0);
2432                if current.is_empty() {
2433                    current.push(p);
2434                }
2435                current.push(at(end));
2436            }
2437            if current.len() >= 2 {
2438                out.push(std::mem::take(&mut current));
2439            } else {
2440                current.clear();
2441            }
2442            for (start, end) in kept {
2443                if end >= 1.0 {
2444                    current = vec![at(start), q];
2445                } else {
2446                    out.push(vec![at(start), at(end)]);
2447                }
2448            }
2449        }
2450        if current.len() >= 2 {
2451            out.push(current);
2452        }
2453    }
2454    out
2455}
2456
2457/// Break `lines` around the text `runs` — the door [`super::dimension`] uses,
2458/// so a sheet dimension's value is cleared out of its own dimension line by
2459/// the very same pass that clears a placed annotation's.
2460pub(super) fn break_for_text_boxes(
2461    lines: Vec<Vec<[f64; 2]>>,
2462    runs: &[SheetText],
2463) -> Vec<Vec<[f64; 2]>> {
2464    let boxes: Vec<Box2> = runs.iter().map(Box2::of_text).collect();
2465    break_for_text(lines, &boxes)
2466}
2467
2468/// The parameter spans of segment `p`→`q` that lie OUTSIDE every box, as
2469/// `(start, end)` pairs in increasing order.
2470fn visible_spans(p: [f64; 2], q: [f64; 2], boxes: &[Box2]) -> Vec<(f64, f64)> {
2471    let mut blocked: Vec<(f64, f64)> = Vec::new();
2472    for box2 in boxes {
2473        let (Some(a), Some(b)) = (box2.local(p), box2.local(q)) else {
2474            continue;
2475        };
2476        // Liang-Barsky against the box's two slabs.
2477        let (mut t0, mut t1) = (0.0f64, 1.0f64);
2478        let mut inside = true;
2479        for (axis, half) in [(0usize, box2.half[0]), (1usize, box2.half[1])] {
2480            let delta = b[axis] - a[axis];
2481            if delta.abs() < 1e-12 {
2482                if a[axis].abs() > half {
2483                    inside = false;
2484                    break;
2485                }
2486                continue;
2487            }
2488            let (lo, hi) = ((-half - a[axis]) / delta, (half - a[axis]) / delta);
2489            let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
2490            t0 = t0.max(lo);
2491            t1 = t1.min(hi);
2492            if t0 > t1 {
2493                inside = false;
2494                break;
2495            }
2496        }
2497        if inside && t1 > t0 {
2498            blocked.push((t0, t1));
2499        }
2500    }
2501    if blocked.is_empty() {
2502        return vec![(0.0, 1.0)];
2503    }
2504    blocked.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
2505    let mut spans = Vec::new();
2506    let mut cursor = 0.0f64;
2507    for (start, end) in blocked {
2508        if start > cursor + 1e-9 {
2509            spans.push((cursor, start.min(1.0)));
2510        }
2511        cursor = cursor.max(end);
2512        if cursor >= 1.0 {
2513            break;
2514        }
2515    }
2516    if cursor < 1.0 - 1e-9 {
2517        spans.push((cursor, 1.0));
2518    }
2519    spans
2520}
2521
2522/// Whether an annotation plane is EDGE-ON to a view looking along `forward`:
2523/// its normal perpendicular to the viewing direction, so the plane and
2524/// everything laid out in it project onto one line. An annotation with no
2525/// picked plane is laid out in the view's own plane and is never edge-on.
2526pub fn plane_is_edge_on(row: &PmiAnnotationReport, forward: [f64; 3]) -> bool {
2527    match row.plane.as_ref() {
2528        None => false,
2529        Some(plane) => dot(norm(plane.normal), forward).abs() < EDGE_ON_COS,
2530    }
2531}
2532
2533/// The view's PMI dimensions a sheet dimension can inherit a tolerance from:
2534/// every enabled, resolved free linear, radial or diametral row whose params
2535/// carry a tolerance block or the reference flag (the kernel's own
2536/// `ToleranceBlock::read`, against the document's expressions), with its
2537/// measured points through the view camera.
2538fn tolerance_sources(
2539    report: Option<&PmiViewReport>,
2540    view: &PmiView,
2541    frame: &ViewFrame,
2542    placed: &PlacedView,
2543    env: &brep_kernel::Env,
2544) -> Vec<ToleranceSource> {
2545    let Some(report) = report else { return Vec::new() };
2546    let project = |p: [f64; 3]| -> [f64; 2] {
2547        let v = frame.to_view(p);
2548        to_paper(placed, v[0], v[1])
2549    };
2550    let mut out = Vec::new();
2551    for row in report.annotations.iter().filter(|row| row.enabled && row.status == PmiStatus::Ok) {
2552        let Some(annotation) = view.annotations.iter().find(|annotation| annotation.id() == row.id) else { continue };
2553        let Ok(block) = brep_kernel::ToleranceBlock::read(annotation, env) else { continue };
2554        let reference = annotation.flag("isReference");
2555        if block.mode == brep_kernel::ToleranceMode::None && !reference {
2556            continue;
2557        }
2558        let measures = match &row.geometry {
2559            brep_kernel::PmiGeometry::Linear { a, b, component: None } => Measures::Linear { a: project(*a), b: project(*b) },
2560            brep_kernel::PmiGeometry::Radial { center, radius, diameter, sphere: false, .. } => {
2561                Measures::Radial { centre: project(*center), radius: *radius, diameter: *diameter }
2562            }
2563            _ => continue,
2564        };
2565        out.push(ToleranceSource { id: row.id.clone(), measures, block, reference });
2566    }
2567    out
2568}
2569
2570fn annotations(
2571    report: Option<&PmiViewReport>,
2572    view: &PmiView,
2573    frame: &ViewFrame,
2574    placed: &PlacedView,
2575) -> Vec<AnnotationDrawing> {
2576    let Some(report) = report else {
2577        return Vec::new();
2578    };
2579    // The text is sized on PAPER: a view's text size is in points, so the
2580    // model-unit cap height the layout needs is the paper height over the
2581    // placement's scale.
2582    let paper_text = view.display.text_size_pt * MM_PER_POINT;
2583    let text_height = paper_text / placed.scale.max(1e-9);
2584    let style = PmiLayoutStyle {
2585        arrow: text_height * ARROW_PER_TEXT,
2586        text_height,
2587        view_dir: frame.forward,
2588        view_up: frame.up,
2589        plane: None,
2590    };
2591    let flatten = placed.flatten_text;
2592    let mut out = Vec::new();
2593    for row in &report.annotations {
2594        if !row.enabled || row.status != PmiStatus::Ok {
2595            continue;
2596        }
2597        let row_style = PmiLayoutStyle { plane: row.plane, ..style };
2598        let drawn = pmi_present(&row.geometry, row.label_world, &row.text, &row_style);
2599        // Paper millimetres, through the view camera — the ONE map every
2600        // piece of an annotation's geometry goes through.
2601        let project = |p: [f64; 3]| -> [f64; 2] {
2602            let v = frame.to_view(p);
2603            to_paper(placed, v[0], v[1])
2604        };
2605        let project_line = |polyline: &Vec<[f64; 3]>| -> Vec<[f64; 2]> {
2606            polyline.iter().map(|p| project(*p)).collect()
2607        };
2608        let mut lines: Vec<Vec<[f64; 2]>> = drawn.polylines.iter().map(project_line).collect();
2609        // Arrowheads are CLOSED outlines here: the sheet's output contract is
2610        // lines and text, so a filled head is drawn as its border.
2611        for arrow in &drawn.arrows {
2612            let mut closed: Vec<[f64; 3]> = arrow.to_vec();
2613            closed.push(arrow[0]);
2614            lines.push(project_line(&closed));
2615        }
2616
2617        // Every run of one annotation is laid out in the SAME in-plane basis
2618        // (the layout's `right` / `up`), so the first run carries it — read
2619        // rather than re-derived, because the basis is the kernel's. A FRAMED
2620        // symbol — an FCF's cells, a datum's box — is then ONE unit about the
2621        // annotation's anchor: flattening rotates the frame and its cells
2622        // together, so the cells keep their order and their spacing.
2623        let unit = drawn
2624            .texts
2625            .first()
2626            .filter(|_| !drawn.frames.is_empty())
2627            .map(|run| Unit {
2628                anchor: drawn.text_anchor,
2629                right: run.right,
2630                up: run.up,
2631                cap: run.height,
2632            });
2633        // A point of a FLATTENED piece: its offset from the anchor measured
2634        // in the layout's own in-plane basis, laid straight onto the paper at
2635        // sheet scale — the anchor's PROJECTED position kept, everything
2636        // about it turned flat and back to its unforeshortened size.
2637        let flat_about = |anchor: [f64; 3], right3: [f64; 3], up3: [f64; 3], p: [f64; 3]| -> [f64; 2] {
2638            let d = sub(p, anchor);
2639            let a = project(anchor);
2640            [a[0] + dot(d, right3) * placed.scale, a[1] - dot(d, up3) * placed.scale]
2641        };
2642
2643        let texts: Vec<SheetText> = drawn
2644            .texts
2645            .iter()
2646            .filter(|run| !run.text.trim().is_empty())
2647            .map(|run| {
2648                let cap = run.height * placed.scale;
2649                if flatten {
2650                    // Reading left to right at the run's sheet-scale size,
2651                    // about its own anchor — or about the unit's, when it is
2652                    // one cell of a framed symbol that turns as a whole.
2653                    let anchor = match unit {
2654                        Some(unit) => flat_about(unit.anchor, unit.right, unit.up, run.anchor),
2655                        None => project(run.anchor),
2656                    };
2657                    return SheetText::flat(run.text.clone(), anchor, cap);
2658                }
2659                // The paper image of one cap height along a unit direction.
2660                let image = |v: [f64; 3]| -> [f64; 2] {
2661                    [dot(v, frame.right) * cap, -dot(v, frame.up) * cap]
2662                };
2663                let mut right = image(run.right);
2664                // A plane seen from BEHIND mirrors its geometry — that is
2665                // what a projection does — but never its text: negating the
2666                // reading direction spans the very same parallelogram, so the
2667                // box stays where it lies and the glyphs still read forward.
2668                if dot(cross(run.right, run.up), frame.forward) > 0.0 {
2669                    right = [-right[0], -right[1]];
2670                }
2671                SheetText::new(run.text.clone(), project(run.anchor), right, image(run.up))
2672            })
2673            .collect();
2674
2675        // The frames follow their unit; everything else — leaders, dimension
2676        // and extension lines, arrowheads — is projected either way.
2677        let frames: Vec<Vec<[f64; 2]>> = match (flatten, unit) {
2678            (true, Some(unit)) => drawn
2679                .frames
2680                .iter()
2681                .map(|polyline| {
2682                    polyline
2683                        .iter()
2684                        .map(|p| flat_about(unit.anchor, unit.right, unit.up, *p))
2685                        .collect()
2686                })
2687                .collect(),
2688            _ => drawn.frames.iter().map(project_line).collect(),
2689        };
2690        // A framed symbol's outline is exempt from the line breaking (it
2691        // would erase itself) and instead becomes a box of its own, which is
2692        // what re-attaches the leader to the flattened frame's nearest edge.
2693        let mut boxes: Vec<Box2> = texts.iter().map(Box2::of_text).collect();
2694        if let Some(unit) = unit {
2695            let scaled = unit.cap * placed.scale;
2696            let (right, up) = if flatten {
2697                ([scaled, 0.0], [0.0, -scaled])
2698            } else {
2699                let image = |v: [f64; 3]| [dot(v, frame.right) * scaled, -dot(v, frame.up) * scaled];
2700                (image(unit.right), image(unit.up))
2701            };
2702            if let Some(box2) = unit.box_of(&drawn.frames, project(unit.anchor), right, up) {
2703                boxes.push(box2);
2704            }
2705        }
2706        let mut lines = break_for_text(lines, &boxes);
2707        lines.extend(frames);
2708
2709        out.push(AnnotationDrawing {
2710            id: row.id.clone(),
2711            kind: row.kind.clone(),
2712            text: row.text.clone(),
2713            lines,
2714            texts,
2715            note: if !plane_is_edge_on(row, frame.forward) {
2716                String::new()
2717            } else if flatten {
2718                format!(
2719                    "annotation plane edge-on to '{}': its geometry projects to a line, its text is flattened onto the sheet",
2720                    view.name
2721                )
2722            } else {
2723                format!(
2724                    "annotation plane edge-on to '{}': it and its text project to a line",
2725                    view.name
2726                )
2727            },
2728        });
2729    }
2730    out
2731}
2732
2733/// A FRAMED symbol — an FCF's row of cells, a datum's lettered box — as one
2734/// piece: the annotation's anchor and the layout's own in-plane basis, with
2735/// the cap height its sizes are measured in. The frame and its cells flatten
2736/// together about `anchor`, which is what keeps the cells in order and
2737/// evenly spaced whatever the annotation plane does to the view.
2738#[derive(Debug, Clone, Copy)]
2739struct Unit {
2740    anchor: [f64; 3],
2741    right: [f64; 3],
2742    up: [f64; 3],
2743    cap: f64,
2744}
2745
2746impl Unit {
2747    /// The box the unit's outline occupies, measured in the layout's own
2748    /// basis about the anchor and mapped to paper through the basis it was
2749    /// DRAWN with — so an oblique plane with the toggle off gives the
2750    /// projected parallelogram and not its bounding rectangle. No clearance:
2751    /// a leader is meant to TOUCH the frame.
2752    fn box_of(
2753        &self,
2754        frames: &[Vec<[f64; 3]>],
2755        anchor: [f64; 2],
2756        right: [f64; 2],
2757        up: [f64; 2],
2758    ) -> Option<Box2> {
2759        if self.cap <= 0.0 {
2760            return None;
2761        }
2762        let mut lo = [f64::INFINITY; 2];
2763        let mut hi = [f64::NEG_INFINITY; 2];
2764        for polyline in frames {
2765            for p in polyline {
2766                let d = sub(*p, self.anchor);
2767                let local = [dot(d, self.right) / self.cap, dot(d, self.up) / self.cap];
2768                for axis in 0..2 {
2769                    lo[axis] = lo[axis].min(local[axis]);
2770                    hi[axis] = hi[axis].max(local[axis]);
2771                }
2772            }
2773        }
2774        if lo[0] > hi[0] {
2775            return None;
2776        }
2777        let mid = [(lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5];
2778        Some(Box2 {
2779            centre: [
2780                anchor[0] + mid[0] * right[0] + mid[1] * up[0],
2781                anchor[1] + mid[0] * right[1] + mid[1] * up[1],
2782            ],
2783            right,
2784            up,
2785            half: [(hi[0] - lo[0]) * 0.5, (hi[1] - lo[1]) * 0.5],
2786        })
2787    }
2788}
2789
2790
2791