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