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