Skip to main content

geop_ops_drawing/
drawing.rs

1//! What a drawing shows ([`DrawingArgs`]), and composing it on a sheet
2//! ([`compose`]): the views laid out in third- or first-angle projection,
3//! each with its visible and hidden lines, centre marks on its circles and
4//! its overall size dimensioned, the dimensions asked for, a section view
5//! with its cut hatched, a border and a title block — and, if asked for,
6//! the parts list of the parts placed in it above the title block, each
7//! line ballooned where its part is drawn.
8
9use geop_core_editor::selectable::Selectable;
10use geop_core_geometry::{
11    aabb::union_aabb,
12    intersection::curve_curve_overlaps_and_crossings,
13    nurb_curve::{NurbCurve2D, NurbCurve3D},
14    search::{MAX_NODES, min_subdivision_size},
15    shape::{Arc, Circle},
16};
17use geop_core_math::{
18    geop_error::{GeopError, GeopResult, WithContext},
19    plain::{self, add, normalize, sub},
20    primitives::Pose,
21    scalars::Scalar,
22    solvers::{
23        probe::{Probe, attempts},
24        repeat::{Step, repeat},
25    },
26    vector::{Vector2, Vector3},
27};
28use geop_core_program::{placed::Located, reader::Reader};
29use geop_core_sketch::dimension::Measure;
30use geop_core_topology::{FaceId, Model};
31use geop_lib_sheet::{Anchor, Layer, P, Shape, Sheet, decimal, strokes_of};
32use geop_ops_model::placed::locate;
33use geop_ops_model::sketch::SketchPlane;
34use geop_ops_model::{CosmeticThread, Edge, NamedModel, Vertex, element, frame_along};
35use serde::{Deserialize, Serialize};
36
37use crate::{
38    annotation::{Annotation, Candidate, Drawn, candidates},
39    hidden_lines::{LineKind, ProjectedView, ViewOptions},
40    layer,
41    scene::{Body, Scene},
42    section::{cut_faces, section_part},
43    view::{DrawnView, ViewFrame, ViewKind},
44};
45
46/// Which side of the front view the other views go.
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum Projection {
50    /// The top view above the front view, the right view to its right —
51    /// each view where it is seen from (ASME).
52    #[default]
53    ThirdAngle,
54    /// The top view below the front view, the right view to its left —
55    /// each view where it is projected onto (ISO).
56    FirstAngle,
57}
58
59impl Projection {
60    pub fn label(self) -> &'static str {
61        match self {
62            Projection::ThirdAngle => "THIRD ANGLE",
63            Projection::FirstAngle => "FIRST ANGLE",
64        }
65    }
66}
67
68/// The paper, in landscape.
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum SheetSize {
72    A4,
73    #[default]
74    A3,
75    A2,
76    A1,
77    A0,
78}
79
80impl SheetSize {
81    pub const ALL: [SheetSize; 5] = [
82        SheetSize::A4,
83        SheetSize::A3,
84        SheetSize::A2,
85        SheetSize::A1,
86        SheetSize::A0,
87    ];
88
89    pub fn name(self) -> &'static str {
90        match self {
91            SheetSize::A4 => "a4",
92            SheetSize::A3 => "a3",
93            SheetSize::A2 => "a2",
94            SheetSize::A1 => "a1",
95            SheetSize::A0 => "a0",
96        }
97    }
98
99    /// Width and height in millimetres.
100    pub fn size(self) -> (f64, f64) {
101        match self {
102            SheetSize::A4 => (297.0, 210.0),
103            SheetSize::A3 => (420.0, 297.0),
104            SheetSize::A2 => (594.0, 420.0),
105            SheetSize::A1 => (841.0, 594.0),
106            SheetSize::A0 => (1189.0, 841.0),
107        }
108    }
109}
110
111/// What a drawing of a part shows.
112#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
113pub struct DrawingArgs {
114    #[serde(default = "default_views")]
115    pub views: Vec<ViewKind>,
116    #[serde(default)]
117    pub projection: Projection,
118    #[serde(default)]
119    pub sheet: SheetSize,
120    /// Paper length per model length; the largest standard scale that fits
121    /// the sheet if not given.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub scale: Option<f64>,
124    #[serde(default)]
125    pub tangent_edges: bool,
126    #[serde(default = "yes")]
127    pub hidden_lines: bool,
128    /// The plane of a section view, `A-A`: the part cut there, seen from
129    /// the side its normal points to, the cut hatched.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub section: Option<SketchPlane>,
132    /// The dimensions, notes and centre marks added on the sheet, each in
133    /// one of its views (see [`Annotation`]).
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub annotations: Vec<Annotation>,
136    /// The part's name, for the title block.
137    #[serde(default)]
138    pub name: String,
139    /// What it is made of, for the title block.
140    #[serde(default)]
141    pub material: String,
142    /// A bill of materials of the parts placed in it, as a table above the
143    /// title block: each part's item number, quantity, name, designation
144    /// and material (see [`PartsListLine`]) — and, with parts placed, a
145    /// balloon per line with its item number, pointing at its part.
146    #[serde(default)]
147    pub bom: bool,
148}
149
150/// A line of a drawing's bill of materials: a kind of part placed — its
151/// item number, how many there are, what it is called, what it is ordered
152/// as (`ISO 4762 M4x12`, empty for a part that is made) and made of — and
153/// where it is placed, as the entities of placed parts are named: `screw`,
154/// `arm/screw` for one placed in the part placed as `arm`, `""` for the
155/// part drawn. Who lists the parts placed is the caller's: the drawing lays
156/// them out, and balloons them where they are placed.
157#[derive(Clone, Debug, Default, PartialEq)]
158pub struct PartsListLine {
159    pub item: String,
160    pub quantity: u64,
161    pub name: String,
162    pub designation: String,
163    pub material: String,
164    pub placements: Vec<String>,
165}
166
167fn default_views() -> Vec<ViewKind> {
168    vec![
169        ViewKind::Front,
170        ViewKind::Top,
171        ViewKind::Right,
172        ViewKind::Iso,
173    ]
174}
175
176fn yes() -> bool {
177    true
178}
179
180impl Default for DrawingArgs {
181    fn default() -> Self {
182        DrawingArgs {
183            views: default_views(),
184            projection: Projection::default(),
185            sheet: SheetSize::default(),
186            scale: None,
187            tangent_edges: false,
188            hidden_lines: true,
189            section: None,
190            annotations: Vec::new(),
191            name: String::new(),
192            material: String::new(),
193            bom: false,
194        }
195    }
196}
197
198/// The scales a drawing is made at, largest first.
199pub const SCALES: [f64; 15] = [
200    50.0, 20.0, 10.0, 5.0, 2.0, 1.0, 0.5, 0.2, 0.1, 0.05, 0.02, 0.01, 0.005, 0.002, 0.001,
201];
202/// The margin between the paper's edge and its border, in millimetres.
203const MARGIN: f64 = 10.0;
204/// The space around each view, for its dimensions, in millimetres.
205const GAP: f64 = 28.0;
206/// The title block's size, in millimetres.
207const TITLE_WIDTH: f64 = 180.0;
208const TITLE_HEIGHT: f64 = 40.0;
209/// Text heights, in millimetres.
210pub(crate) const TEXT: f64 = 3.5;
211const TITLE_TEXT: f64 = 7.0;
212/// Arrowheads, in millimetres.
213pub(crate) const ARROW_LENGTH: f64 = 3.0;
214pub(crate) const ARROW_WIDTH: f64 = 1.0;
215/// How far dimension lines stand off what they measure, in millimetres.
216pub(crate) const DIMENSION_OFFSET: f64 = 10.0;
217/// How far centre lines reach past their circle, in millimetres.
218pub(crate) const CENTER_OVERSHOOT: f64 = 3.0;
219/// Hatch line spacing, in millimetres.
220const HATCH_SPACING: f64 = 3.0;
221/// The bill of materials' rows, and its columns' captions and widths — as
222/// wide, together, as the title block it stands on — in millimetres.
223const LIST_ROW: f64 = 6.0;
224const LIST_COLUMNS: [(&str, f64); 5] = [
225    ("ITEM", 14.0),
226    ("QTY", 12.0),
227    ("NAME", 54.0),
228    ("DESIGNATION", 60.0),
229    ("MATERIAL", 40.0),
230];
231const LIST_TEXT: f64 = 2.5;
232/// Item-number balloons: their radius, how far they stand off the box of
233/// the view they go around — past its overall dimensions — and the paper
234/// that view needs around it for them; the dot their leaders end in.
235const BALLOON_RADIUS: f64 = 4.0;
236const BALLOON_OFFSET: f64 = 22.0;
237const BALLOON_MARGIN: f64 = BALLOON_OFFSET + BALLOON_RADIUS;
238const LEADER_DOT: f64 = 0.5;
239
240/// A scale as written: `1:2`, `5:1`.
241pub fn scale_label(scale: f64) -> String {
242    if scale >= 1.0 {
243        format!("{}:1", length_label(scale))
244    } else {
245        format!("1:{}", length_label(1.0 / scale))
246    }
247}
248
249/// A length as written on a drawing: to a hundredth, without trailing
250/// zeros.
251pub fn length_label(x: f64) -> String {
252    decimal(x, 2)
253}
254
255/// A view placed on the sheet.
256pub(crate) struct Placed {
257    pub(crate) kind: DrawnView,
258    pub(crate) view: ProjectedView,
259    /// Hatched regions of a section view, as boundary curves on the paper
260    /// in model units, one list per cut face.
261    hatched: Vec<Hatched>,
262    /// Its box on the paper in model units, `[x_min, y_min, x_max, y_max]`.
263    pub(crate) extents: [f64; 4],
264    /// Where its box's centre lands on the sheet.
265    pub(crate) center: P,
266    /// The paper it needs around its box, besides the gap between views,
267    /// in millimetres: for balloons.
268    margin: f64,
269}
270
271/// Its column and row in the layout grid, row 0 at the top: the front view
272/// in the middle, the others around it as `projection` says.
273fn cell(view: DrawnView, projection: Projection) -> (usize, usize) {
274    let third = projection == Projection::ThirdAngle;
275    match view {
276        DrawnView::View(ViewKind::Front) => (1, 1),
277        DrawnView::View(ViewKind::Top) => (1, if third { 0 } else { 2 }),
278        DrawnView::View(ViewKind::Bottom) => (1, if third { 2 } else { 0 }),
279        DrawnView::View(ViewKind::Right) => (if third { 2 } else { 0 }, 1),
280        DrawnView::View(ViewKind::Left) => (if third { 0 } else { 2 }, 1),
281        DrawnView::View(ViewKind::Back) => (3, 1),
282        DrawnView::View(ViewKind::Iso) => (if third { 2 } else { 0 }, if third { 0 } else { 2 }),
283        DrawnView::Section => (if third { 2 } else { 0 }, if third { 2 } else { 0 }),
284    }
285}
286
287/// The faces a drawing of `part` shows: every face of its solids and
288/// sheets, in a fixed order.
289pub fn drawn_faces(model: &Model) -> Vec<FaceId> {
290    let mut faces: Vec<FaceId> = model.faces.keys().copied().collect();
291    faces.sort_by_key(|f| f.0);
292    faces
293}
294
295/// The part's drawing as `args` describe it, dated `date`, with `parts`
296/// its bill of materials if `args` asks for one: its layout (see
297/// [`layout`]), with its annotations drawn on it. One that does not resolve
298/// — its view gone, an entity it names gone — is refused, naming it.
299pub fn compose(
300    view: &Reader,
301    args: &DrawingArgs,
302    date: &str,
303    parts: &[PartsListLine],
304) -> GeopResult<Sheet> {
305    annotated(view, args, &layout(view, args, date, parts)?)
306}
307
308/// `layout`'s sheet, of `part`, with `args`' annotations drawn on it (see
309/// [`compose`]).
310pub fn annotated(view: &Reader, args: &DrawingArgs, layout: &Layout) -> GeopResult<Sheet> {
311    let mut sheet = layout.sheet.clone();
312    for (index, annotation) in args.annotations.iter().enumerate() {
313        annotation
314            .drawn(view, layout)
315            .map_err(|e| {
316                e.with_context(format!(
317                    "annotation {}: {}",
318                    index + 1,
319                    annotation.describe()
320                ))
321            })?
322            .draw(&mut sheet);
323    }
324    Ok(sheet)
325}
326
327/// A drawing laid out on its sheet: the sheet with everything on it but
328/// the annotations, where its views are placed on it, the scale they are
329/// drawn at, and what can be picked in them to annotate (see
330/// [`Candidate`]).
331#[derive(Clone, Debug)]
332pub struct Layout {
333    pub sheet: Sheet,
334    pub(crate) views: Vec<Placement>,
335    pub scale: f64,
336    pub candidates: Vec<Candidate>,
337}
338
339/// Where a view is placed on the sheet: the middle of its box on its
340/// paper, in model units, lands on `center`.
341#[derive(Clone, Copy, Debug)]
342pub(crate) struct Placement {
343    pub(crate) view: DrawnView,
344    mid: P,
345    center: P,
346}
347
348impl Placement {
349    pub(crate) fn of(p: &Placed) -> Self {
350        Placement {
351            view: p.kind,
352            mid: [
353                (p.extents[0] + p.extents[2]) / 2.0,
354                (p.extents[1] + p.extents[3]) / 2.0,
355            ],
356            center: p.center,
357        }
358    }
359
360    /// Where a point of its paper, in model units, lands on the sheet
361    /// drawn at `scale`.
362    pub(crate) fn place(&self, scale: f64, q: P) -> P {
363        [
364            self.center[0] + scale * (q[0] - self.mid[0]),
365            self.center[1] + scale * (q[1] - self.mid[1]),
366        ]
367    }
368}
369
370impl Layout {
371    /// The view `view` placed, or why there is none.
372    pub(crate) fn placement(&self, view: DrawnView) -> GeopResult<&Placement> {
373        self.views.iter().find(|p| p.view == view).ok_or_else(|| {
374            GeopError::new(format!(
375                "the drawing has no {view} view any more: show it again, or remove what is in it"
376            ))
377        })
378    }
379}
380
381/// The part's drawing as `args` describe it, without its annotations (see
382/// [`compose`]): its views projected (see [`project`]) and laid out (see
383/// [`arrange`]), dated `date`, with `parts` its bill of materials if `args`
384/// asks for one.
385pub fn layout(
386    view: &Reader,
387    args: &DrawingArgs,
388    date: &str,
389    parts: &[PartsListLine],
390) -> GeopResult<Layout> {
391    arrange(view, args, date, parts, &project(view, args)?)
392}
393
394/// A drawing's views projected, each with its lines visible and hidden —
395/// and the section's, with its cut faces to hatch: what laying a drawing
396/// out costs. It depends on no more of the drawing's arguments than
397/// [`Projected::depends_on`] keeps, so changing the paper, the scale, the title
398/// block or the bill of materials projects nothing again.
399#[derive(Clone)]
400pub struct Projected {
401    views: Vec<(DrawnView, ProjectedView, Vec<Hatched>)>,
402}
403
404impl Projected {
405    /// What of `args` projecting the views depends on: the views, the
406    /// lines drawn, the section.
407    pub fn depends_on(args: &DrawingArgs) -> DrawingArgs {
408        DrawingArgs {
409            views: args.views.clone(),
410            hidden_lines: args.hidden_lines,
411            tangent_edges: args.tangent_edges,
412            section: args.section.clone(),
413            ..DrawingArgs::default()
414        }
415    }
416}
417
418/// The views of `part` that `args` ask for, projected (see [`Projected`]):
419/// its own solids and sheets, and every part placed in it, however deep,
420/// where it is placed (see [`Scene`]).
421pub fn project(view: &Reader, args: &DrawingArgs) -> GeopResult<Projected> {
422    let ctx = |e: GeopError| e.with_context("project(drawing)");
423    let scene = Scene::of(view).with_context(&ctx)?;
424    let options = ViewOptions {
425        tangent_edges: args.tangent_edges,
426        hidden_lines: args.hidden_lines,
427    };
428    let mut views = Vec::new();
429    if scene.bodies.is_empty() {
430        return Ok(Projected { views });
431    }
432    for &kind in &args.views {
433        if views.iter().any(|(v, _, _)| *v == DrawnView::View(kind)) {
434            continue;
435        }
436        let frame = kind.frame()?;
437        let view = scene
438            .project(&frame, &options)
439            .map_err(|e| ctx(e.with_context(format!("the {} view", kind.name()))))?;
440        views.push((DrawnView::View(kind), view, Vec::new()));
441    }
442    if let Some(plane) = &args.section {
443        let (section, hatched) = section_view(view, &scene, plane, &options).map_err(|e| {
444            ctx(e.with_context(format!("the section view on {}", plane.picked().label())))
445        })?;
446        views.push((DrawnView::Section, section, hatched));
447    }
448    Ok(Projected { views })
449}
450
451/// The views `projected` of `part` laid out on the sheet `args` describe
452/// (see [`layout`]), dated `date`, with `parts` its bill of materials if
453/// `args` asks for one.
454///
455/// With a bill of materials, each line of it placed in the drawing gets a
456/// balloon with its item number, its leader pointing at its part in one
457/// view (see [`balloon_view`]). A bill too long for the sheet is refused.
458pub fn arrange(
459    view: &Reader,
460    args: &DrawingArgs,
461    date: &str,
462    parts: &[PartsListLine],
463    projected: &Projected,
464) -> GeopResult<Layout> {
465    let ctx = |e: GeopError| e.with_context("arrange(drawing)");
466    let (width, height) = args.sheet.size();
467    let list_height = match args.bom {
468        true => LIST_ROW * (parts.len() + 1) as f64,
469        false => 0.0,
470    };
471    if list_height > height - 2.0 * MARGIN - TITLE_HEIGHT {
472        return Err(GeopError::new(format!(
473            "the bill of materials has {} lines, too many for an {} sheet: choose a larger one",
474            parts.len(),
475            args.sheet.name().to_uppercase()
476        )));
477    }
478    let scene = Scene::of(view).with_context(&ctx)?;
479    if scene.bodies.is_empty() && !args.bom {
480        return Err(GeopError::new(
481            "the part has nothing to draw: it has no faces",
482        ));
483    }
484    let options = ViewOptions {
485        tangent_edges: args.tangent_edges,
486        hidden_lines: args.hidden_lines,
487    };
488    let mut placed: Vec<Placed> = projected
489        .views
490        .iter()
491        .filter_map(|(kind, view, hatched)| {
492            let e = view.extents()?;
493            Some(Placed {
494                kind: *kind,
495                extents: [e[0], e[1], e[2], e[3]].map(|x| x.to_f64()),
496                view: view.clone(),
497                hatched: hatched.clone(),
498                center: [0.0, 0.0],
499                margin: 0.0,
500            })
501        })
502        .collect();
503    if placed.is_empty() && !args.bom {
504        return Err(GeopError::new("no view of the drawing shows any line"));
505    }
506    let mut sheet = Sheet {
507        width,
508        height,
509        layers: layer::ALL.to_vec(),
510        ..Sheet::default()
511    };
512    if placed.is_empty() {
513        // Its bill of materials alone.
514        let scale = args.scale.unwrap_or(1.0);
515        draw_frame(&mut sheet, args, scale, date);
516        draw_parts_list(&mut sheet, parts);
517        return Ok(Layout {
518            sheet,
519            views: Vec::new(),
520            scale,
521            candidates: Vec::new(),
522        });
523    }
524    let balloons = match args.bom && scene.bodies.iter().any(|b| !b.path.is_empty()) {
525        true => balloon_view(&scene, &placed, parts)?,
526        false => None,
527    };
528    if let Some((index, _)) = &balloons {
529        placed[*index].margin = BALLOON_MARGIN;
530    }
531
532    // The grid: each occupied column as wide as its widest view, each row
533    // as high as its highest, in model units — and with as much paper
534    // either side of its views as the widest margin of one asks for.
535    let mut columns: Vec<(usize, f64, f64)> = Vec::new();
536    let mut rows: Vec<(usize, f64, f64)> = Vec::new();
537    for p in &placed {
538        let (c, r) = cell(p.kind, args.projection);
539        let (w, h) = (p.extents[2] - p.extents[0], p.extents[3] - p.extents[1]);
540        grow(&mut columns, c, w, p.margin);
541        grow(&mut rows, r, h, p.margin);
542    }
543    columns.sort_by_key(|&(c, _, _)| c);
544    rows.sort_by_key(|&(r, _, _)| r);
545    let model_width: f64 = columns.iter().map(|&(_, w, _)| w).sum();
546    let model_height: f64 = rows.iter().map(|&(_, h, _)| h).sum();
547    let paper_width: f64 = columns.iter().map(|&(_, _, m)| 2.0 * m).sum();
548    let paper_height: f64 = rows.iter().map(|&(_, _, m)| 2.0 * m).sum();
549    let area_width = width - 2.0 * MARGIN;
550    let area_height = height - 2.0 * MARGIN - TITLE_HEIGHT - list_height;
551    let fit = ((area_width - paper_width - GAP * (columns.len() + 1) as f64)
552        / model_width.max(1e-300))
553    .min((area_height - paper_height - GAP * (rows.len() + 1) as f64) / model_height.max(1e-300));
554    let scale = match args.scale {
555        Some(s) if s.is_finite() && s > 0.0 => s,
556        Some(s) => {
557            return Err(GeopError::new(format!(
558                "the scale {s} is not a positive number"
559            )));
560        }
561        None => SCALES
562            .iter()
563            .copied()
564            .find(|&s| s <= fit)
565            .unwrap_or(SCALES[SCALES.len() - 1]),
566    };
567    // Centred on the sheet's area above the title block and the bill.
568    let used_width = scale * model_width + paper_width + GAP * (columns.len() - 1) as f64;
569    let used_height = scale * model_height + paper_height + GAP * (rows.len() - 1) as f64;
570    let left = MARGIN + (area_width - used_width) / 2.0;
571    let top = height - MARGIN - (area_height - used_height) / 2.0;
572    for p in &mut placed {
573        let (c, r) = cell(p.kind, args.projection);
574        let ci = columns.iter().position(|&(k, _, _)| k == c).unwrap_or(0);
575        let ri = rows.iter().position(|&(k, _, _)| k == r).unwrap_or(0);
576        let x: f64 = left
577            + columns[..ci]
578                .iter()
579                .map(|&(_, w, m)| scale * w + 2.0 * m + GAP)
580                .sum::<f64>()
581            + columns[ci].2
582            + scale * columns[ci].1 / 2.0;
583        let y: f64 = top
584            - rows[..ri]
585                .iter()
586                .map(|&(_, h, m)| scale * h + 2.0 * m + GAP)
587                .sum::<f64>()
588            - rows[ri].2
589            - scale * rows[ri].1 / 2.0;
590        p.center = [x, y];
591    }
592
593    for p in &placed {
594        draw_view(&mut sheet, p, scale).with_context(&ctx)?;
595    }
596    draw_threads(&mut sheet, &scene, &placed, scale, &options).with_context(&ctx)?;
597    if let Some((index, targets)) = &balloons {
598        draw_balloons(&mut sheet, &placed[*index], targets, scale).with_context(&ctx)?;
599    }
600    draw_frame(&mut sheet, args, scale, date);
601    if args.bom {
602        draw_parts_list(&mut sheet, parts);
603    }
604    let candidates = candidates(&scene, &placed, scale).with_context(&ctx)?;
605    Ok(Layout {
606        sheet,
607        views: placed.iter().map(Placement::of).collect(),
608        scale,
609        candidates,
610    })
611}
612
613/// What a balloon points at: an item number, and a point of its part on
614/// the paper of its view, in model units.
615type Target = (String, P);
616
617/// The view the balloons go around, by its index in `placed`, and each
618/// balloon's target: the view in which the most lines of the bill show a
619/// visible line of their parts — the isometric one first among equals,
620/// which sees each part from where most of it shows. A balloon's leader
621/// points at the point of its part's lines in the view farthest out from
622/// the view's centre, of those a quarter, half and three quarters along
623/// each: a visible one, or a hidden one if that is all it has there. A
624/// point far out is one a leader reaches without crossing other parts, and
625/// one inside a line is no corner other lines share.
626///
627/// A line of the bill with no faces drawn — a wire, or a part placed
628/// nowhere in the drawing — gets no balloon.
629fn balloon_view(
630    scene: &Scene<'_>,
631    placed: &[Placed],
632    parts: &[PartsListLine],
633) -> GeopResult<Option<(usize, Vec<Target>)>> {
634    // The line of the bill each body is counted in.
635    let mut line_of: Vec<Option<usize>> = vec![None; scene.bodies.len()];
636    let paths: std::collections::HashMap<&str, usize> = parts
637        .iter()
638        .enumerate()
639        .flat_map(|(k, line)| line.placements.iter().map(move |p| (p.as_str(), k)))
640        .collect();
641    for (b, body) in scene.bodies.iter().enumerate() {
642        line_of[b] = paths.get(body.path.as_str()).copied();
643    }
644    let mut order: Vec<usize> = (0..placed.len())
645        .filter(|&i| matches!(placed[i].kind, DrawnView::View(_)))
646        .collect();
647    order.sort_by_key(|&i| placed[i].kind != DrawnView::View(ViewKind::Iso));
648    let mut best: Option<(usize, usize, Vec<Target>)> = None;
649    for i in order {
650        let p = &placed[i];
651        let middle = [
652            (p.extents[0] + p.extents[2]) / 2.0,
653            (p.extents[1] + p.extents[3]) / 2.0,
654        ];
655        // Per line of the bill: whether its point is seen, how far out it
656        // is, and where.
657        let mut found: Vec<Option<(bool, f64, P)>> = vec![None; parts.len()];
658        for l in &p.view.lines {
659            let Some(k) = line_of[l.body] else { continue };
660            let (t0, t1) = l.curve.domain();
661            for f in [0.25, 0.5, 0.75] {
662                let q = l
663                    .curve
664                    .evaluate(Scalar::fraction(t0, t1, Scalar::from_f64(f)))?;
665                let q = [q[0].to_f64(), q[1].to_f64()];
666                let out = (q[0] - middle[0]).hypot(q[1] - middle[1]);
667                if found[k].is_none_or(|(visible, o, _)| (l.visible, out) > (visible, o)) {
668                    found[k] = Some((l.visible, out, q));
669                }
670            }
671        }
672        let seen = found.iter().flatten().filter(|f| f.0).count();
673        let targets = found
674            .iter()
675            .zip(parts)
676            .filter_map(|(f, line)| f.map(|(_, _, q)| (line.item.clone(), q)))
677            .collect();
678        if best.as_ref().is_none_or(|(_, s, _)| seen > *s) {
679            best = Some((i, seen, targets));
680        }
681    }
682    Ok(best
683        .filter(|(_, _, targets)| !targets.is_empty())
684        .map(|(i, _, targets)| (i, targets)))
685}
686
687/// The point `s` along the rectangle `r` (`[x_min, y_min, x_max, y_max]`),
688/// counter-clockwise from its lower left corner.
689fn along_rectangle(r: [f64; 4], s: f64) -> P {
690    let (w, h) = (r[2] - r[0], r[3] - r[1]);
691    let s = s.rem_euclid(2.0 * (w + h));
692    if s < w {
693        [r[0] + s, r[1]]
694    } else if s < w + h {
695        [r[2], r[1] + s - w]
696    } else if s < 2.0 * w + h {
697        [r[2] - (s - w - h), r[3]]
698    } else {
699        [r[0], r[3] - (s - 2.0 * w - h)]
700    }
701}
702
703/// How far along the rectangle `r` (see [`along_rectangle`]) the ray from
704/// `from`, inside it, along `d` leaves it.
705fn rectangle_exit(r: [f64; 4], from: P, d: P) -> f64 {
706    let (w, h) = (r[2] - r[0], r[3] - r[1]);
707    // The side the ray reaches first, and how far along the rectangle.
708    let mut best = (f64::INFINITY, 0.0);
709    // Bottom, right, top, left: the coordinate fixed on each, its value,
710    // and where along the rectangle it starts.
711    let sides = [
712        (1, r[1], 0.0),
713        (0, r[2], w),
714        (1, r[3], w + h),
715        (0, r[0], 2.0 * w + h),
716    ];
717    for (k, &(axis, value, start)) in sides.iter().enumerate() {
718        if d[axis] == 0.0 {
719            continue;
720        }
721        let t = (value - from[axis]) / d[axis];
722        if t <= 0.0 || t >= best.0 {
723            continue;
724        }
725        let q = add(from, plain::scale(d, t));
726        let along = match k {
727            0 => q[0] - r[0],
728            1 => q[1] - r[1],
729            2 => r[2] - q[0],
730            _ => r[3] - q[1],
731        };
732        best = (t, start + along);
733    }
734    best.1
735}
736
737/// The balloons around the view `p`: each a circle with its item number,
738/// on a rectangle standing `BALLOON_OFFSET` off the view's box, its leader
739/// running to a dot on its target. Each goes where the ray from the view's
740/// centre through its target meets the rectangle, and they are spread
741/// along it, in that order, until none overlaps the next: so leaders run
742/// outwards, and do not cross.
743fn draw_balloons(sheet: &mut Sheet, p: &Placed, targets: &[Target], scale: f64) -> GeopResult<()> {
744    let place = placer(p, scale);
745    let [x0, y0, x1, y1] = p.extents;
746    let (lo, hi) = (place([x0, y0]), place([x1, y1]));
747    let ring = [
748        lo[0] - BALLOON_OFFSET,
749        lo[1] - BALLOON_OFFSET,
750        hi[0] + BALLOON_OFFSET,
751        hi[1] + BALLOON_OFFSET,
752    ];
753    let perimeter = 2.0 * (ring[2] - ring[0] + ring[3] - ring[1]);
754    let spacing = 2.0 * BALLOON_RADIUS + BALLOON_RADIUS / 2.0;
755    if targets.len() as f64 * spacing > perimeter {
756        return Err(GeopError::new(format!(
757            "{} balloons do not fit around the view: choose a larger sheet",
758            targets.len()
759        )));
760    }
761    let mut balloons: Vec<(f64, &Target)> = targets
762        .iter()
763        .map(|target| {
764            let d = sub(place(target.1), p.center);
765            let d = if d == [0.0, 0.0] { [0.0, 1.0] } else { d };
766            (rectangle_exit(ring, p.center, d), target)
767        })
768        .collect();
769    balloons.sort_by(|a, b| a.0.total_cmp(&b.0));
770    // Spread forwards — and, should the last then run into the first round
771    // the rectangle, backwards from there.
772    for k in 1..balloons.len() {
773        balloons[k].0 = balloons[k].0.max(balloons[k - 1].0 + spacing);
774    }
775    let n = balloons.len();
776    if n > 1 && balloons[n - 1].0 > balloons[0].0 + perimeter - spacing {
777        balloons[n - 1].0 = balloons[0].0 + perimeter - spacing;
778        for k in (0..n - 1).rev() {
779            balloons[k].0 = balloons[k].0.min(balloons[k + 1].0 - spacing);
780        }
781    }
782    for (s, (item, target)) in balloons {
783        let center = along_rectangle(ring, s);
784        let at = place(*target);
785        // Any way out where the balloon is on its target.
786        let toward = normalize(sub(at, center)).unwrap_or([1.0, 0.0]);
787        sheet.stroke(
788            layer::DIMENSION,
789            Shape::Circle {
790                center,
791                radius: BALLOON_RADIUS,
792            },
793        );
794        sheet.stroke(
795            layer::DIMENSION,
796            Shape::Line(add(center, plain::scale(toward, BALLOON_RADIUS)), at),
797        );
798        sheet.stroke(
799            layer::DIMENSION,
800            Shape::Circle {
801                center: at,
802                radius: LEADER_DOT,
803            },
804        );
805        sheet.label(
806            layer::DIMENSION,
807            [center[0], center[1] - TEXT / 2.0],
808            TEXT,
809            Anchor::Middle,
810            item.clone(),
811        );
812    }
813    Ok(())
814}
815
816/// The bill of materials as a table standing on the title block, as wide
817/// as it: its captions in the bottom row, the first item above them. A
818/// text too long for its column is cut short, ending in `…`.
819fn draw_parts_list(sheet: &mut Sheet, parts: &[PartsListLine]) {
820    let x0 = sheet.width - MARGIN - TITLE_WIDTH;
821    let y0 = MARGIN + TITLE_HEIGHT;
822    let rows = parts.len() + 1;
823    let y1 = y0 + LIST_ROW * rows as f64;
824    for k in 1..=rows {
825        let y = y0 + LIST_ROW * k as f64;
826        sheet.stroke(layer::BORDER, Shape::Line([x0, y], [x0 + TITLE_WIDTH, y]));
827    }
828    let mut x = x0;
829    for (_, w) in LIST_COLUMNS {
830        sheet.stroke(layer::BORDER, Shape::Line([x, y0], [x, y1]));
831        x += w;
832    }
833    sheet.stroke(layer::BORDER, Shape::Line([x, y0], [x, y1]));
834    let row = |sheet: &mut Sheet, k: usize, cells: [&str; 5]| {
835        let mut x = x0;
836        for ((_, w), text) in LIST_COLUMNS.iter().zip(cells) {
837            // An average glyph is about 0.6 of the text's height wide.
838            let fits = ((w - 2.0) / (0.6 * LIST_TEXT)) as usize;
839            let text = match text.chars().count() > fits {
840                true => format!(
841                    "{}…",
842                    text.chars()
843                        .take(fits.saturating_sub(1))
844                        .collect::<String>()
845                ),
846                false => text.to_string(),
847            };
848            let at = [
849                x + 1.0,
850                y0 + LIST_ROW * k as f64 + (LIST_ROW - LIST_TEXT) / 2.0,
851            ];
852            sheet.label(layer::BORDER, at, LIST_TEXT, Anchor::Start, text);
853            x += w;
854        }
855    };
856    row(sheet, 0, LIST_COLUMNS.map(|(caption, _)| caption));
857    for (k, line) in parts.iter().enumerate() {
858        let quantity = line.quantity.to_string();
859        let cells = [
860            line.item.as_str(),
861            quantity.as_str(),
862            line.name.as_str(),
863            line.designation.as_str(),
864            line.material.as_str(),
865        ];
866        row(sheet, k + 1, cells);
867    }
868}
869
870/// How a view sees a thread's axis.
871enum ThreadSeen {
872    /// Square to the axis: the thread from the side.
873    Side,
874    /// Along the axis: the thread end on.
875    End,
876}
877
878/// The part's cosmetic threads, as drafting draws them, in every
879/// orthographic view seeing their axis from the side or end on: from the
880/// side two thin lines along the thread, at its minor diameter on a shaft
881/// and at its major diameter in a hole, closing in or opening out along a
882/// tapered thread; end on, three quarters of a thin circle at that
883/// diameter, at the end nearer the eye. Each on [`layer::HIDDEN`] where the threaded
884/// face is hidden there, and labelled with its designation in the first
885/// view that draws it. A view seeing the axis obliquely, and the section
886/// view, draw no threads.
887///
888/// The threads of the parts placed are drawn where they are placed, and
889/// not labelled: the bill of materials designates the parts they are on.
890fn draw_threads(
891    sheet: &mut Sheet,
892    scene: &Scene<'_>,
893    placed: &[Placed],
894    scale: f64,
895    options: &ViewOptions,
896) -> GeopResult<()> {
897    for body in &scene.bodies {
898        let mut threads: Vec<_> = body.threads.iter().flat_map(|t| t.iter()).collect();
899        threads.sort_by_key(|(name, _)| *name);
900        for (name, thread) in threads {
901            let ctx = |e: GeopError| match body.path.as_str() {
902                "" => e.with_context(format!("the cosmetic thread {name}")),
903                path => e.with_context(format!(
904                    "the cosmetic thread {name} of the part placed as {path}"
905                )),
906            };
907            let (mut start, mut along) = (thread.axis.point, thread.axis.direction);
908            if let Some(pose) = &body.pose {
909                let motion = pose.motion();
910                start = motion.apply(&start);
911                along = motion.rotate(&along);
912            }
913            let seen = Seen {
914                scene,
915                placed,
916                scale,
917                options,
918            };
919            draw_thread(sheet, &seen, thread, start, along, body.path.is_empty())
920                .with_context(&ctx)?;
921        }
922    }
923    Ok(())
924}
925
926/// What drawing a cosmetic thread looks at: the bodies drawn, the views
927/// placed and the sheet's scale.
928struct Seen<'a, 'p> {
929    scene: &'a Scene<'p>,
930    placed: &'a [Placed],
931    scale: f64,
932    options: &'a ViewOptions,
933}
934
935/// The cosmetic thread `thread`, from `start` along the unit vector
936/// `along`, in every view that sees it (see [`draw_threads`]), labelled
937/// in the first if `label`.
938fn draw_thread(
939    sheet: &mut Sheet,
940    seen: &Seen<'_, '_>,
941    thread: &CosmeticThread,
942    start: Vector3,
943    along: Vector3,
944    label: bool,
945) -> GeopResult<()> {
946    let Seen {
947        scene,
948        placed,
949        scale,
950        options,
951    } = *seen;
952    let end = start.add(&along.prod_scalar(Scalar::from_f64(thread.length)));
953    let drawn = if thread.internal {
954        thread.major_diameter
955    } else {
956        thread.minor_diameter
957    } / 2.0;
958    // A tapered thread's radius `along` from its start.
959    let taper = thread.taper.to_f64();
960    let drawn_at = |along: f64| drawn + taper * along;
961    let mut labelled = !label;
962    for p in placed
963        .iter()
964        .filter(|p| matches!(p.kind, DrawnView::View(k) if k != ViewKind::Iso))
965    {
966        let frame = &p.view.frame;
967        let look = frame.direction.vector();
968        let seen = if look.prod_dot(&along).could_be_equal(Scalar::ZERO) {
969            ThreadSeen::Side
970        } else if look
971            .prod_cross(&along)
972            .to_array()
973            .iter()
974            .all(|c| c.could_be_equal(Scalar::ZERO))
975        {
976            ThreadSeen::End
977        } else {
978            continue;
979        };
980        let place = placer(p, scale);
981        let at = |q: &Vector3| {
982            let v = frame.project_point(q);
983            place([v[0].to_f64(), v[1].to_f64()])
984        };
985        // Whether the threaded face is seen: from the side, at its
986        // point nearest the eye halfway along — on a shaft that is
987        // in front, in a hole behind the material round it; end on,
988        // where the thread is drawn at its end nearer the eye.
989        let mut end_radius = drawn;
990        let (probe, label_at) = match seen {
991            ThreadSeen::Side => {
992                let half = thread.length / 2.0;
993                let middle = start.add(&along.prod_scalar(Scalar::from_f64(half)));
994                let radius = thread.radius.add(thread.taper.mul(Scalar::from_f64(half)));
995                (
996                    middle.add(&frame.toward_eye().prod_scalar(radius)),
997                    at(&end),
998                )
999            }
1000            ThreadSeen::End => {
1001                let (near, drawn) = if frame
1002                    .direction
1003                    .dot(&start)
1004                    .definitely_greater(frame.direction.dot(&end))
1005                {
1006                    (end, drawn_at(thread.length))
1007                } else {
1008                    (start, drawn)
1009                };
1010                end_radius = drawn;
1011                let frame_there = frame_along(near, &along)?;
1012                let side = *frame_there.u();
1013                let c = at(&near);
1014                let reach = scale * drawn * std::f64::consts::FRAC_1_SQRT_2;
1015                (
1016                    near.add(&side.prod_scalar(Scalar::from_f64(drawn))),
1017                    [c[0] + reach, c[1] + reach],
1018                )
1019            }
1020        };
1021        let visible = scene.point_seen(frame, probe)?;
1022        if !visible && !options.hidden_lines {
1023            continue;
1024        }
1025        let layer = if visible {
1026            layer::THREAD
1027        } else {
1028            layer::HIDDEN
1029        };
1030        match seen {
1031            ThreadSeen::Side => {
1032                let side = look.prod_cross(&along).normalize()?;
1033                let off = |r: f64| side.prod_scalar(Scalar::from_f64(r));
1034                let (first, last) = (off(drawn), off(drawn_at(thread.length)));
1035                for (first, last) in [(first, last), (first.neg(), last.neg())] {
1036                    sheet.stroke(
1037                        layer,
1038                        Shape::Line(at(&start.add(&first)), at(&end.add(&last))),
1039                    );
1040                }
1041            }
1042            ThreadSeen::End => {
1043                // Open in the quarter up and to the right, as drafting
1044                // leaves it, a little turned.
1045                sheet.stroke(
1046                    layer,
1047                    Shape::Arc {
1048                        center: at(&start),
1049                        radius: scale * end_radius,
1050                        start: 100f64.to_radians(),
1051                        end: 10f64.to_radians(),
1052                    },
1053                );
1054            }
1055        }
1056        if !labelled {
1057            sheet.label(
1058                layer::DIMENSION,
1059                [label_at[0] + 1.5, label_at[1] + 1.5],
1060                TEXT,
1061                Anchor::Start,
1062                thread.designation.clone(),
1063            );
1064            labelled = true;
1065        }
1066    }
1067    Ok(())
1068}
1069
1070/// Makes `cells` hold `key` at least `size`, with at least `margin`.
1071fn grow(cells: &mut Vec<(usize, f64, f64)>, key: usize, size: f64, margin: f64) {
1072    match cells.iter_mut().find(|(k, _, _)| *k == key) {
1073        Some((_, s, m)) => {
1074            *s = s.max(size);
1075            *m = m.max(margin);
1076        }
1077        None => cells.push((key, size, margin)),
1078    }
1079}
1080
1081/// Where a point of the view `p`, in model units on its paper, lands on
1082/// the sheet.
1083pub(crate) fn placer(p: &Placed, scale: f64) -> impl Fn(P) -> P {
1084    let placement = Placement::of(p);
1085    move |q: P| placement.place(scale, q)
1086}
1087
1088/// The view `p`'s lines, hatching, centre marks, overall dimensions and
1089/// label.
1090fn draw_view(sheet: &mut Sheet, p: &Placed, scale: f64) -> GeopResult<()> {
1091    let place = placer(p, scale);
1092    let curves: Vec<(Layer, &NurbCurve2D)> = p
1093        .view
1094        .lines
1095        .iter()
1096        .map(|line| {
1097            let layer = if line.visible {
1098                layer::VISIBLE
1099            } else {
1100                layer::HIDDEN
1101            };
1102            (layer, &line.curve)
1103        })
1104        .collect();
1105    sheet.strokes.extend(strokes_of(&curves, &place, scale)?);
1106    for (boundary, mirrored) in &p.hatched {
1107        for (a, b) in hatch(boundary, scale, *mirrored)? {
1108            sheet.stroke(layer::HATCH, Shape::Line(place(a), place(b)));
1109        }
1110    }
1111    if p.kind.orthographic() {
1112        center_marks(sheet, p, scale)?;
1113        // The overall size: the width below the view, the height to its
1114        // right.
1115        let [x0, y0, x1, y1] = p.extents;
1116        let (a, b) = (place([x0, y0]), place([x1, y0]));
1117        let below = [(a[0] + b[0]) / 2.0, a[1] - DIMENSION_OFFSET];
1118        Drawn::measure(
1119            Measure::Linear {
1120                a,
1121                b,
1122                along: [1.0, 0.0],
1123            },
1124            below,
1125            length_label(x1 - x0),
1126        )
1127        .draw(sheet);
1128        let (a, b) = (place([x1, y0]), place([x1, y1]));
1129        let right = [a[0] + DIMENSION_OFFSET, (a[1] + b[1]) / 2.0];
1130        Drawn::measure(
1131            Measure::Linear {
1132                a,
1133                b,
1134                along: [0.0, 1.0],
1135            },
1136            right,
1137            length_label(y1 - y0),
1138        )
1139        .draw(sheet);
1140    }
1141    if p.kind == DrawnView::Section {
1142        let [_, y0, _, _] = p.extents;
1143        let below = place([(p.extents[0] + p.extents[2]) / 2.0, y0]);
1144        sheet.label(
1145            layer::DIMENSION,
1146            [below[0], below[1] - DIMENSION_OFFSET - 2.5 * TEXT],
1147            TEXT * 1.4,
1148            Anchor::Middle,
1149            "SECTION A-A",
1150        );
1151    }
1152    Ok(())
1153}
1154
1155/// Centre marks on the circles the view `p` sees round: on every centre
1156/// with a circular edge turning through half a turn or more around it, two
1157/// centre lines across its largest such circle.
1158fn center_marks(sheet: &mut Sheet, p: &Placed, scale: f64) -> GeopResult<()> {
1159    // (centre, radius, sweep in radians)
1160    let mut circles: Vec<(Vector3, Scalar, f64)> = Vec::new();
1161    for line in p.view.lines.iter().filter(|l| l.kind == LineKind::Edge) {
1162        let lifted = line.curve.lifted()?;
1163        let Some(arc) = arc_of(&lifted)? else {
1164            continue;
1165        };
1166        let sweep = arc.sweep();
1167        let (center, radius) = (arc.circle.center, arc.circle.radius);
1168        match circles
1169            .iter_mut()
1170            .find(|(c, r, _)| c.could_be_equal(&center) && r.could_be_equal(radius))
1171        {
1172            Some((_, _, s)) => *s += sweep,
1173            None => circles.push((center, radius, sweep)),
1174        }
1175    }
1176    let mut marks: Vec<(Vector3, f64)> = Vec::new();
1177    for (center, radius, sweep) in circles {
1178        if sweep < std::f64::consts::PI * 0.999 {
1179            continue;
1180        }
1181        let r = radius.to_f64();
1182        match marks.iter_mut().find(|(c, _)| c.could_be_equal(&center)) {
1183            Some((_, m)) => *m = m.max(r),
1184            None => marks.push((center, r)),
1185        }
1186    }
1187    let place = placer(p, scale);
1188    for (center, r) in marks {
1189        let c = place([center[0].to_f64(), center[1].to_f64()]);
1190        let reach = r * scale + CENTER_OVERSHOOT;
1191        sheet.stroke(
1192            layer::CENTER,
1193            Shape::Line([c[0] - reach, c[1]], [c[0] + reach, c[1]]),
1194        );
1195        sheet.stroke(
1196            layer::CENTER,
1197            Shape::Line([c[0], c[1] - reach], [c[0], c[1] + reach]),
1198        );
1199    }
1200    Ok(())
1201}
1202
1203/// The part the entity `name` is of — the part `reader` reads, or one
1204/// placed in it, however deep, the name behind its path: `/arm/screw:…`
1205/// (see [`Located::key`]) — where that part is placed (`None` for the part
1206/// read itself), and the entity as that part names it.
1207fn owner<'a>(reader: &Reader<'a>, name: &str) -> GeopResult<(Reader<'a>, Option<Pose>, String)> {
1208    let located = Located::of_key(name)?;
1209    let (part, pose) = match located.path() {
1210        "" => (reader.clone(), None),
1211        path => (reader.placed_part(path)?, Some(locate(reader, path)?.world)),
1212    };
1213    Ok((part, pose, located.name().to_string()))
1214}
1215
1216/// Where the vertex `name` of the part `reader` reads — or of a part placed
1217/// in it, named as that part names it — is.
1218pub(crate) fn placed_vertex(reader: &Reader, name: &str) -> GeopResult<Vector3> {
1219    let (part, pose, local) = owner(reader, name)?;
1220    let found = part.retrieve_element(&element::<Vertex>(local))?;
1221    let point = found.model.topology().get_vertex(found.id)?.point;
1222    Ok(match pose {
1223        Some(pose) => pose.apply(&point),
1224        None => point,
1225    })
1226}
1227
1228/// The curve of the edge `name` of the part `reader` reads — or of a part
1229/// placed in it, named as that part names it — where it is.
1230pub(crate) fn placed_edge(reader: &Reader, name: &str) -> GeopResult<NurbCurve3D> {
1231    let (part, pose, local) = owner(reader, name)?;
1232    let found = part.retrieve_element(&element::<Edge>(local))?;
1233    let curve = &found.model.topology().get_edge(found.id)?.curve;
1234    Ok(match pose {
1235        Some(pose) => curve.transform(&pose.motion()),
1236        None => curve.clone(),
1237    })
1238}
1239
1240/// The arc `curve` runs along, if it is circular: built as the kernel
1241/// builds arcs ([`NurbCurve3D::as_arc`]), or any curve lying on the circle
1242/// through three of its points — its ends and middle, or for a closed one
1243/// three points a third apart, free choices — such as a hole's rim the
1244/// boolean traced where a cylinder cuts a face. Reconstructed here, where a
1245/// drawing dimensions and marks circles, rather than kept as a kind in the
1246/// B-rep: whether the curve lies on that circle is the overlap the kernel's
1247/// coincidence search finds, from end to end, not a guess from samples. Two
1248/// more points off the circle refuse most curves before it runs.
1249pub(crate) fn arc_of(curve: &NurbCurve3D) -> GeopResult<Option<Arc>> {
1250    if let Some(arc) = curve.as_arc()? {
1251        return Ok(Some(arc));
1252    }
1253    if curve.as_line()?.is_some() {
1254        return Ok(None);
1255    }
1256    let (t0, t1) = curve.domain();
1257    let at = |f: f64| curve.evaluate(Scalar::fraction(t0, t1, Scalar::from_f64(f)).sharpen());
1258    let (start, end) = (curve.evaluate(t0)?, curve.evaluate(t1)?);
1259    let through = if start.could_be_equal(&end) {
1260        [start, at(1.0 / 3.0)?, at(2.0 / 3.0)?]
1261    } else {
1262        [start, at(0.5)?, end]
1263    };
1264    let Some(circle) = circle_through(through)? else {
1265        return Ok(None);
1266    };
1267    for f in [0.25, 0.75] {
1268        let off = at(f)?.sub(&circle.center).norm();
1269        if !off.could_be_equal(circle.radius) {
1270            return Ok(None);
1271        }
1272    }
1273    let arc = Arc { circle, start, end };
1274    let (overlaps, _) = curve_curve_overlaps_and_crossings(
1275        curve,
1276        &arc.to_curve()?,
1277        Scalar::ZERO,
1278        MAX_NODES,
1279        min_subdivision_size(),
1280    )?;
1281    let whole =
1282        (overlaps.iter()).any(|o| o.start.t.could_be_equal(t0) && o.end.t.could_be_equal(t1));
1283    Ok(whole.then_some(arc))
1284}
1285
1286/// The circle through the three points `[p, q, r]`, turning from `p`
1287/// through `q` to `r` counter-clockwise about its normal; `None` where they
1288/// could lie on one line. Worked relative to a sharp point beside `p`, a
1289/// free choice, so its width does not grow with the distance from the
1290/// origin: the centre is `p + ((|u|² w - |w|² u) × (u × w)) / (2 |u × w|²)`
1291/// for `u = q - p`, `w = r - p`.
1292fn circle_through([p, q, r]: [Vector3; 3]) -> GeopResult<Option<Circle>> {
1293    let origin = p.sharpen();
1294    let (p, q, r) = (p.sub(&origin), q.sub(&origin), r.sub(&origin));
1295    let (u, w) = (q.sub(&p), r.sub(&p));
1296    let n = u.prod_cross(&w);
1297    let n2 = n.norm_sq();
1298    if n2.could_be_equal(Scalar::ZERO) {
1299        return Ok(None);
1300    }
1301    let towards = w
1302        .prod_scalar(u.norm_sq())
1303        .sub(&u.prod_scalar(w.norm_sq()))
1304        .prod_cross(&n);
1305    let center = p.add(&towards.prod_scalar(Scalar::ONE.div(n2.mul(Scalar::TWO))?));
1306    Ok(Some(Circle {
1307        center: origin.add(&center),
1308        normal: n.normalize()?,
1309        radius: p.sub(&center).norm(),
1310    }))
1311}
1312
1313/// The border and the title block.
1314fn draw_frame(sheet: &mut Sheet, args: &DrawingArgs, scale: f64, date: &str) {
1315    let (w, h) = (sheet.width, sheet.height);
1316    let corners =
1317        |x0: f64, y0: f64, x1: f64, y1: f64| vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]];
1318    sheet.stroke(
1319        layer::BORDER,
1320        Shape::Polyline(corners(MARGIN, MARGIN, w - MARGIN, h - MARGIN)),
1321    );
1322    let (x0, y0) = (w - MARGIN - TITLE_WIDTH, MARGIN);
1323    let (x1, y1) = (w - MARGIN, MARGIN + TITLE_HEIGHT);
1324    sheet.stroke(layer::BORDER, Shape::Polyline(corners(x0, y0, x1, y1)));
1325    let row = TITLE_HEIGHT / 4.0;
1326    let half = x0 + TITLE_WIDTH / 2.0;
1327    for k in 1..4 {
1328        let y = y0 + row * k as f64;
1329        sheet.stroke(layer::BORDER, Shape::Line([x0, y], [x1, y]));
1330    }
1331    // The bottom two rows hold two fields each.
1332    sheet.stroke(
1333        layer::BORDER,
1334        Shape::Line([half, y0], [half, y0 + 2.0 * row]),
1335    );
1336    let name = if args.name.is_empty() {
1337        "PART"
1338    } else {
1339        args.name.as_str()
1340    };
1341    let field = |sheet: &mut Sheet, x: f64, y: f64, caption: &str, value: &str| {
1342        sheet.label(
1343            layer::BORDER,
1344            [x + 2.0, y + row - 3.0],
1345            2.0,
1346            Anchor::Start,
1347            caption,
1348        );
1349        sheet.label(
1350            layer::BORDER,
1351            [x + 2.0, y + 1.5],
1352            TEXT,
1353            Anchor::Start,
1354            value,
1355        );
1356    };
1357    sheet.label(
1358        layer::BORDER,
1359        [x0 + 3.0, y0 + 3.0 * row + 1.5],
1360        TITLE_TEXT * 0.8,
1361        Anchor::Start,
1362        name,
1363    );
1364    let material = if args.material.is_empty() {
1365        "-"
1366    } else {
1367        args.material.as_str()
1368    };
1369    field(sheet, x0, y0 + 2.0 * row, "MATERIAL", material);
1370    field(sheet, x0, y0 + row, "SCALE", &scale_label(scale));
1371    field(sheet, half, y0 + row, "UNITS", "mm");
1372    field(sheet, x0, y0, "DATE", date);
1373    field(sheet, half, y0, "PROJECTION", args.projection.label());
1374}
1375
1376/// A region of a section view to hatch: its boundary curves on the paper,
1377/// in model units, and whether its hatching is mirrored — which it is on
1378/// every other part cut, so that two parts cut side by side tell apart.
1379type Hatched = (Vec<NurbCurve2D>, bool);
1380
1381/// A body's part cut, with the cutting plane's origin and normal in its
1382/// own frame.
1383type Cut = (NamedModel, Vector3, Vector3);
1384
1385/// The drawing's bodies (`scene`, of `part`) cut at `plane`, seen from the
1386/// side its normal points to, and their cut faces' boundaries for hatching.
1387///
1388/// Each body is cut in its own part's frame (see [`section_part`]): a body
1389/// wholly on the side the normal points to is gone, one wholly on the
1390/// other side, or with no solid to cut, is drawn whole. Refused if no body
1391/// has a solid to cut.
1392fn section_view(
1393    view: &Reader,
1394    scene: &Scene<'_>,
1395    plane: &SketchPlane,
1396    options: &ViewOptions,
1397) -> GeopResult<(ProjectedView, Vec<Hatched>)> {
1398    let frame_of = plane.resolve(view)?;
1399    let normal = frame_of.w().normalize()?;
1400    let origin = *frame_of.origin();
1401    if scene.bodies.iter().all(|b| b.model().solids.is_empty()) {
1402        return Err(GeopError::new("the part has no solid to cut"));
1403    }
1404    // Up on the paper: `z`, unless the line of sight runs nearly along it
1405    // — then the world axis least along the line of sight, chosen as every
1406    // direction across an axis is, not by noise (see `across`).
1407    let up = if normal[2].to_f64().abs() < 0.9 {
1408        Vector3::axis(2)
1409    } else {
1410        normal.across()?[0]
1411    };
1412    let frame = ViewFrame::looking(normal.neg(), up)?;
1413    // Each body as it is drawn: whole, or its part cut where the plane is
1414    // in its own frame.
1415    let mut cuts: Vec<(usize, Option<Cut>)> = Vec::new();
1416    for (k, body) in scene.bodies.iter().enumerate() {
1417        if body.model().solids.is_empty() {
1418            cuts.push((k, None));
1419            continue;
1420        }
1421        match scene.side_of(k, &origin, &normal) {
1422            Some(true) => continue,
1423            Some(false) => cuts.push((k, None)),
1424            None => {
1425                let (o, n) = match &body.pose {
1426                    Some(pose) => {
1427                        let back = pose.inverse().motion();
1428                        (back.apply(&origin), back.rotate(&normal))
1429                    }
1430                    None => (origin, normal),
1431                };
1432                let cut = section_part(body.named, &o, &n).map_err(|e| {
1433                    e.with_context(format!("cutting the part placed as {:?}", body.path))
1434                })?;
1435                cuts.push((k, Some((cut, o, n))));
1436            }
1437        }
1438    }
1439    let bodies = cuts
1440        .iter()
1441        .map(|(k, cut)| {
1442            let body = &scene.bodies[*k];
1443            match cut {
1444                None => Body {
1445                    path: body.path.clone(),
1446                    named: body.named,
1447                    threads: body.threads,
1448                    faces: body.faces.clone(),
1449                    pose: body.pose,
1450                },
1451                Some((cut, _, _)) => Body {
1452                    path: body.path.clone(),
1453                    named: cut,
1454                    threads: body.threads,
1455                    faces: drawn_faces(cut.topology()),
1456                    pose: body.pose,
1457                },
1458            }
1459        })
1460        .filter(|b| !b.faces.is_empty())
1461        .collect();
1462    let options = ViewOptions {
1463        hidden_lines: false,
1464        ..*options
1465    };
1466    let view = Scene::new(bodies)?.project(&frame, &options)?;
1467    let mut hatched = Vec::new();
1468    for (index, (k, (cut, o, n))) in cuts
1469        .iter()
1470        .filter_map(|(k, cut)| cut.as_ref().map(|c| (k, c)))
1471        .enumerate()
1472    {
1473        let model = cut.topology();
1474        let motion = scene.bodies[*k].pose.map(|p| p.motion());
1475        for face in cut_faces(model, &drawn_faces(model), o, n)? {
1476            let mut boundary = Vec::new();
1477            for edge in model.face_edges(face) {
1478                let curve = &model.get_edge(edge)?.curve;
1479                let curve = match &motion {
1480                    Some(m) => curve.transform(m),
1481                    None => curve.clone(),
1482                };
1483                boundary.push(frame.project_curve(&curve)?);
1484            }
1485            hatched.push((boundary, index % 2 == 1));
1486        }
1487    }
1488    Ok((view, hatched))
1489}
1490
1491/// Hatch lines across the region `boundary` bounds (model units on the
1492/// paper), at 45 degrees — or, `mirrored`, at 135 — `HATCH_SPACING` apart
1493/// on a sheet at `scale`.
1494///
1495/// Each line is cut where it crosses the boundary, and alternate stretches
1496/// are inside. A line through a corner of the boundary, or along it, cannot
1497/// be counted that way; where to put the lines is a free choice, so such a
1498/// line is moved a little instead.
1499fn hatch(boundary: &[NurbCurve2D], scale: f64, mirrored: bool) -> GeopResult<Vec<(P, P)>> {
1500    if mirrored {
1501        let flip = |c: &NurbCurve2D| {
1502            let points = c
1503                .control_points
1504                .iter()
1505                .map(|q| Vector3::from_array([q[0].neg(), q[1], q[2]]))
1506                .collect();
1507            NurbCurve2D::try_new(c.degree, points, c.knot_vector.clone())
1508        };
1509        let flipped = boundary.iter().map(flip).collect::<GeopResult<Vec<_>>>()?;
1510        let back = |p: P| [-p[0], p[1]];
1511        return Ok(hatch(&flipped, scale, false)?
1512            .into_iter()
1513            .map(|(a, b)| (back(a), back(b)))
1514            .collect());
1515    }
1516    let Some(bounds) = union_aabb(boundary.iter().map(|c| c.aabb())) else {
1517        return Ok(Vec::new());
1518    };
1519    let lo: [f64; 2] = std::array::from_fn(|k| bounds[k].lower().to_f64());
1520    let hi: [f64; 2] = std::array::from_fn(|k| bounds[k].upper().to_f64());
1521    if !(lo[0] < hi[0] && lo[1] < hi[1]) {
1522        return Ok(Vec::new());
1523    }
1524    // Lines `x - y = c`, from the box's lower right to upper left corner.
1525    let spacing = HATCH_SPACING / scale * std::f64::consts::SQRT_2;
1526    let (c_lo, c_hi) = (lo[0] - hi[1], hi[0] - lo[1]);
1527    let reach = (hi[0] - lo[0]) + (hi[1] - lo[1]) + 1.0;
1528    let mut out = Vec::new();
1529    // One line a spacing on from the one before, from half a spacing in to
1530    // the far corner: as many as the spacings across, and one for the
1531    // rounding, and a step that finds the corner passed.
1532    let mut c = c_lo + spacing / 2.0;
1533    let lines = ((c_hi - c) / spacing).max(0.0).ceil() as usize;
1534    repeat(lines + 2, || -> GeopResult<_> {
1535        if c < c_hi {
1536            let shifts = [0.0, 0.37, -0.29];
1537            let line = attempts(shifts.len(), |&k| -> GeopResult<_> {
1538                let cc = c + shifts[k] * spacing;
1539                // From a point of the line left of the box to one right of it.
1540                let x_start = lo[0] - 1.0;
1541                let a = [x_start, x_start - cc];
1542                let b = [x_start + reach, x_start + reach - cc];
1543                Ok(match hatch_line(boundary, a, b)? {
1544                    Some(stretches) => Probe::Found(stretches),
1545                    None => Probe::Next,
1546                })
1547            })?;
1548            if let Some(stretches) = line {
1549                out.extend(stretches);
1550            }
1551            c += spacing;
1552            Ok(Step::Again)
1553        } else {
1554            Ok(Step::Done(()))
1555        }
1556    })?
1557    .done("hatching across the face")?;
1558    Ok(out)
1559}
1560
1561/// The stretches of the segment from `a` to `b` inside `boundary`, or
1562/// `None` if it passes through a corner of it or runs along it.
1563fn hatch_line(boundary: &[NurbCurve2D], a: P, b: P) -> GeopResult<Option<Vec<(P, P)>>> {
1564    let point = |p: P| Vector2::from_array(p.map(Scalar::from_f64));
1565    let line = NurbCurve2D::line(point(a), point(b))?;
1566    let mut ts: Vec<f64> = Vec::new();
1567    for curve in boundary {
1568        let (overlaps, crossings) = curve_curve_overlaps_and_crossings(
1569            &line,
1570            curve,
1571            Scalar::ZERO,
1572            MAX_NODES,
1573            min_subdivision_size(),
1574        )?;
1575        if !overlaps.is_empty() {
1576            return Ok(None);
1577        }
1578        let (s0, s1) = curve.domain();
1579        for (t, s) in crossings {
1580            if s.could_be_equal(s0) || s.could_be_equal(s1) {
1581                return Ok(None);
1582            }
1583            ts.push(t.midpoint().to_f64());
1584        }
1585    }
1586    if ts.len() % 2 == 1 {
1587        return Ok(None);
1588    }
1589    ts.sort_by(f64::total_cmp);
1590    let at = |t: f64| [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])];
1591    Ok(Some(
1592        ts.chunks(2)
1593            .map(|pair| (at(pair[0]), at(pair[1])))
1594            .collect(),
1595    ))
1596}