Skip to main content

geop_ops_drawing/
operation.rs

1//! [`Drawing`]: a step describing a 2-D drawing of the part as the steps
2//! before it built it — its views, a section, the title block, and the
3//! dimensions, notes and centre marks added on its sheet. It changes
4//! nothing; the drawing is made from it when it is downloaded
5//! ([`crate::drawing::compose`]).
6//!
7//! Edited, it shows its sheet in the viewport — laid in the world's `xy`
8//! plane, a millimetre of paper to a unit, faced head on as a sketch's plane
9//! is — and is annotated there, the way a sketch is dimensioned: a tool
10//! taken up in the dialog picks what the views show (vertices, circles'
11//! centres, edges), the dimension then follows the pointer, and a click
12//! puts its value down. A value is dragged where it goes; Delete removes
13//! what is selected.
14//!
15//! The sheet shown is the one downloaded, but for its date: laying it out
16//! is the costly part, so it is kept in the session for as long as the
17//! part and what the views depend on are the same (see
18//! [`DrawingSession::show`]).
19
20use geop_core_editor::{
21    edit_form::{Edit, EditForm, FormState},
22    editable::{Context, OperationGroup, OperationInfo, OperationTier},
23    editing::{
24        Action, CanvasEvent, Choice, Extent, InHand, ListItem, Pointer, Shape, Style, Tone, Value,
25        Visual,
26        hit::{hit_visuals, no_instances},
27    },
28    part_operation,
29    part_operation::PartOperation,
30};
31use geop_core_program::{part::StepContext, reader::Reader};
32use std::{cell::RefCell, rc::Rc};
33
34use geop_core_editor::output::Exporter;
35use geop_core_editor::view::icons;
36use geop_core_math::{
37    geop_error::{GeopError, GeopResult, WithContext},
38    primitives::{CoordinateSystem, Pose},
39    scalars::Scalar,
40    vector::Vector3,
41    with_context,
42};
43use geop_core_sketch::plain::{add, sub};
44use geop_lib_sheet::{Anchor, Layer, P, Sheet};
45use geop_ops_model::parameters::{Properties, read_properties};
46use geop_ops_model::placed::placed_parts;
47use geop_ops_model::{NamedModel, Threads, model, read_model, read_threads, threads};
48use serde::{Deserialize, Serialize};
49
50use crate::{
51    annotation::{Along, Annotation, Candidate, Drawn, EdgeShape, Leader, Pickable, Target},
52    drawing::{
53        DrawingArgs, Layout, Projected, Projection, SCALES, SheetSize, arc_of, arrange,
54        placed_edge, placed_vertex, project, scale_label,
55    },
56    export::{DrawingExporter, described, drawing},
57    layer,
58    parts::parts_list,
59    view::{DrawnView, ViewKind},
60};
61
62/// Describes a drawing of the part: see [`DrawingArgs`].
63#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
64pub struct Drawing;
65
66/// A tool for annotating the sheet.
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
68pub enum Tool {
69    /// None: a click selects, a value is dragged.
70    #[default]
71    Select,
72    /// Whatever the picks call for: the distance between two points, a
73    /// line's length, the angle between two lines, a circle's diameter or
74    /// an arc's radius.
75    Dimension,
76    Horizontal,
77    Vertical,
78    Radius,
79    Diameter,
80    Angle,
81    Note,
82    CenterMark,
83}
84
85/// The tools, as the dialog offers them: each with its name, its icon and
86/// what it does.
87/// Each tool, its name, its label and its icon.
88const TOOLS: [(Tool, &str, &str, &str); 8] = [
89    (
90        Tool::Dimension,
91        "distance",
92        "Dimension — two points, a line, two lines or a circle",
93        icons::DISTANCE,
94    ),
95    (
96        Tool::Horizontal,
97        "distance_x",
98        "Horizontal distance — two points or a line",
99        icons::DISTANCE_X,
100    ),
101    (
102        Tool::Vertical,
103        "distance_y",
104        "Vertical distance — two points or a line",
105        icons::DISTANCE_Y,
106    ),
107    (
108        Tool::Radius,
109        "radius",
110        "Radius of a circle or an arc",
111        icons::RADIUS,
112    ),
113    (
114        Tool::Diameter,
115        "diameter",
116        "Diameter of a circle or an arc",
117        icons::DIAMETER,
118    ),
119    (
120        Tool::Angle,
121        "angle",
122        "Angle between two lines",
123        icons::ANGLE,
124    ),
125    (
126        Tool::Note,
127        "note",
128        "Note — click what it points at, then where its text goes; or click empty paper",
129        crate::icons::NOTE,
130    ),
131    (
132        Tool::CenterMark,
133        "center_mark",
134        "Centre mark on a circle",
135        crate::icons::CENTER_MARK,
136    ),
137];
138
139impl Tool {
140    fn by_name(name: &str) -> Option<Tool> {
141        TOOLS.iter().find(|t| t.1 == name).map(|t| t.0)
142    }
143
144    fn label(self) -> &'static str {
145        TOOLS.iter().find(|t| t.0 == self).map_or("", |t| t.2)
146    }
147}
148
149/// What editing a drawing keeps between events, besides what is selected
150/// (see [`DrawingEditing`]).
151#[derive(Clone)]
152pub struct DrawingSession {
153    tool: Tool,
154    /// What was picked for the tool so far, all in one view.
155    picks: Vec<Candidate>,
156    /// What a click would pick.
157    hover: Option<Candidate>,
158    /// Where the pointer is on the sheet.
159    cursor: Option<P>,
160    /// The text the next note gets.
161    note: String,
162    /// Why the last annotation picked could not be added.
163    refused: Option<String>,
164    /// The views as last projected, for the part as it then was, and what
165    /// of the arguments they depend on (see [`Projected::depends_on`]).
166    projected: Remembered<Result<Projected, String>>,
167    /// The sheet as last laid out — with its bill of materials — for the
168    /// part as it then was, and the arguments without their annotations.
169    laid_out: Remembered<Result<Layout, String>>,
170}
171
172/// What editing a drawing keeps: its session, and the annotations selected.
173pub type DrawingEditing = FormState<DrawingSession>;
174
175/// What a session keeps of what it worked out: for the part as it then
176/// was, from which arguments, and what.
177type Remembered<T> = RefCell<Option<(Shown, DrawingArgs, Rc<T>)>>;
178
179/// What a drawing of the part a reader reads depends on: its model and its
180/// cosmetic threads, what its program says of it — its name and material,
181/// for the title block — and every part placed in it, by the very build
182/// placed and where. Both models share their structure with the part's, so
183/// comparing two costs what tells them apart.
184#[derive(Clone)]
185struct Shown {
186    model: Option<NamedModel>,
187    threads: Option<Threads>,
188    properties: Properties,
189    /// Each placed part by name, the revision of its part and where it
190    /// is.
191    placed: Vec<(String, u64, Pose)>,
192}
193
194impl Shown {
195    /// What the part `reader` reads is, as a drawing of it depends on.
196    fn of(reader: &Reader) -> GeopResult<Self> {
197        let model = match reader.defines(&model()) {
198            true => Some(read_model(reader)?.into_owned()),
199            false => None,
200        };
201        let threads = match reader.defines(&threads()) {
202            true => Some(read_threads(reader)?.into_owned()),
203            false => None,
204        };
205        Ok(Self {
206            model,
207            threads,
208            properties: read_properties(reader)?.into_owned(),
209            placed: (placed_parts(reader)?.into_iter())
210                .map(|p| (p.name, p.located.part.revision(), p.located.world))
211                .collect(),
212        })
213    }
214}
215
216/// The same part: its model and threads equal, and every part placed
217/// unchanged since, where it was.
218impl PartialEq for Shown {
219    fn eq(&self, other: &Self) -> bool {
220        self.model == other.model
221            && self.threads == other.threads
222            && self.properties == other.properties
223            && self.placed.len() == other.placed.len()
224            && (self.placed.iter().zip(&other.placed)).all(|(a, b)| a == b)
225    }
226}
227
228impl Default for DrawingSession {
229    fn default() -> Self {
230        Self {
231            tool: Tool::Select,
232            picks: Vec::new(),
233            hover: None,
234            cursor: None,
235            note: "NOTE".into(),
236            refused: None,
237            projected: RefCell::new(None),
238            laid_out: RefCell::new(None),
239        }
240    }
241}
242
243impl DrawingSession {
244    /// The tool in hand.
245    pub fn tool(&self) -> Tool {
246        self.tool
247    }
248
249    /// `args`' sheet as drawn of the part `view` shows, the program of the
250    /// file `file`, with its bill of materials if `args` asks for one —
251    /// laid out once, and kept while the part and what the views depend on
252    /// are the same.
253    fn layout(&self, view: &Reader, file: &str, args: &DrawingArgs) -> Rc<Result<Layout, String>> {
254        let shown = match Shown::of(view) {
255            Ok(shown) => shown,
256            Err(e) => return Rc::new(Err(e.root_message().to_string())),
257        };
258        let mut key = args.clone();
259        key.annotations.clear();
260        if let Some((s, a, laid)) = &*self.laid_out.borrow()
261            && *s == shown
262            && *a == key
263        {
264            return laid.clone();
265        }
266        let parts = parts_list(view, file, args).map_err(|e| e.root_message().to_string());
267        let laid = Rc::new(match (parts, &*self.projected(view, &shown, args)) {
268            (Ok(parts), Ok(projected)) => {
269                arrange(view, args, "", &parts, projected).map_err(|e| e.root_message().to_string())
270            }
271            (Err(e), _) => Err(format!("the bill of materials: {e}")),
272            (_, Err(e)) => Err(e.clone()),
273        });
274        *self.laid_out.borrow_mut() = Some((shown, key, laid.clone()));
275        laid
276    }
277
278    /// `args`' views of the part `view` shows, which is `shown`, projected
279    /// — once, and kept while the part and what they depend on are the
280    /// same.
281    fn projected(
282        &self,
283        view: &Reader,
284        shown: &Shown,
285        args: &DrawingArgs,
286    ) -> Rc<Result<Projected, String>> {
287        let key = Projected::depends_on(args);
288        if let Some((s, a, projected)) = &*self.projected.borrow()
289            && s == shown
290            && *a == key
291        {
292            return projected.clone();
293        }
294        let projected = Rc::new(project(view, args).map_err(|e| e.root_message().to_string()));
295        *self.projected.borrow_mut() = Some((shown.clone(), key, projected.clone()));
296        projected
297    }
298}
299
300fn label(kind: ViewKind) -> &'static str {
301    match kind {
302        ViewKind::Front => "Front",
303        ViewKind::Top => "Top",
304        ViewKind::Right => "Right",
305        ViewKind::Left => "Left",
306        ViewKind::Bottom => "Bottom",
307        ViewKind::Back => "Back",
308        ViewKind::Iso => "Isometric",
309    }
310}
311
312/// The plane the sheet lies in: the world's `xy` plane, a millimetre of
313/// paper to a unit.
314fn paper() -> CoordinateSystem {
315    CoordinateSystem::world_at(Vector3::zero())
316}
317
318/// A point of the sheet where the viewport has it.
319fn world(p: P) -> Vector3 {
320    Vector3::from_array([Scalar::from_f64(p[0]), Scalar::from_f64(p[1]), Scalar::ZERO])
321}
322
323/// Where `pointer` points on the sheet.
324fn on_sheet(pointer: &Pointer) -> Option<P> {
325    let plane = paper();
326    let (_, at) = pointer.ray.intersect_plane(plane.origin(), plane.w())?;
327    Some([at[0].to_f64(), at[1].to_f64()])
328}
329
330/// The key of the visual of the `i`-th annotation's value, which selects
331/// and drags it.
332fn annotation_key(i: usize) -> String {
333    format!("annotation{i}")
334}
335
336/// The annotation a visual's key belongs to.
337fn annotation_of(key: &str) -> Option<usize> {
338    key.strip_prefix("annotation")?
339        .split('/')
340        .next()?
341        .parse()
342        .ok()
343}
344
345/// What a candidate is, for what a tool can make of it.
346#[derive(Clone, Copy, PartialEq, Eq)]
347enum Kind {
348    Point,
349    Line,
350    Circle,
351    Other,
352}
353
354fn kind(c: &Candidate) -> Kind {
355    match &c.what {
356        Pickable::Point { .. } => Kind::Point,
357        Pickable::Edge { shape, .. } => match shape {
358            EdgeShape::Line { .. } => Kind::Line,
359            EdgeShape::Circle { .. } => Kind::Circle,
360            EdgeShape::Other => Kind::Other,
361        },
362    }
363}
364
365/// The picks a tool takes, complete: every pick in one view — one that
366/// shows lengths as they are, but for a note.
367fn patterns(tool: Tool) -> &'static [&'static [Kind]] {
368    use Kind::*;
369    match tool {
370        Tool::Select => &[],
371        Tool::Dimension => &[&[Point, Point], &[Line], &[Line, Line], &[Circle]],
372        Tool::Horizontal | Tool::Vertical => &[&[Point, Point], &[Line]],
373        Tool::Radius | Tool::Diameter | Tool::CenterMark => &[&[Circle]],
374        Tool::Angle => &[&[Line, Line]],
375        Tool::Note => &[&[Point], &[Line], &[Circle], &[Other]],
376    }
377}
378
379/// Whether `picks` are all `tool` needs, or can still grow into it.
380fn fits(tool: Tool, picks: &[&Candidate]) -> bool {
381    let Some(first) = picks.first() else {
382        return true;
383    };
384    if picks.iter().any(|c| c.view != first.view)
385        || (tool != Tool::Note && !first.view.orthographic())
386    {
387        return false;
388    }
389    let kinds: Vec<Kind> = picks.iter().map(|c| kind(c)).collect();
390    patterns(tool)
391        .iter()
392        .any(|p| p.len() >= kinds.len() && p[..kinds.len()] == kinds[..])
393}
394
395/// What `tool` makes of `picks`, if they are all it needs — its value not
396/// yet placed.
397fn build(tool: Tool, picks: &[Candidate]) -> Option<Annotation> {
398    let refs: Vec<&Candidate> = picks.iter().collect();
399    let view = picks.first()?.view;
400    if !fits(tool, &refs) || !patterns(tool).iter().any(|p| p.len() == picks.len()) {
401        return None;
402    }
403    let point = |c: &Candidate| match &c.what {
404        Pickable::Point { target, .. } => Some(target.clone()),
405        _ => None,
406    };
407    let edge = |c: &Candidate| match &c.what {
408        Pickable::Edge { name, shape, .. } => Some((name.clone(), shape.clone())),
409        _ => None,
410    };
411    let distance = |along: Along| -> Option<Annotation> {
412        let (from, to) = match picks {
413            [a, b] => (point(a)?, point(b)?),
414            [line] => match edge(line)?.1 {
415                EdgeShape::Line { start, end } => {
416                    (Target::Vertex { name: start }, Target::Vertex { name: end })
417                }
418                _ => return None,
419            },
420            _ => return None,
421        };
422        Some(Annotation::Distance {
423            view,
424            from,
425            to,
426            along,
427            label: [0.0, 0.0],
428        })
429    };
430    let radius = |diameter: bool| {
431        Some(Annotation::Radius {
432            view,
433            edge: edge(&picks[0])?.0,
434            diameter,
435            label: [0.0, 0.0],
436        })
437    };
438    match tool {
439        Tool::Select => None,
440        Tool::Dimension => match picks {
441            [a, b] if kind(a) == Kind::Line => Some(Annotation::Angle {
442                view,
443                a: edge(a)?.0,
444                b: edge(b)?.0,
445                label: [0.0, 0.0],
446            }),
447            [c] if kind(c) == Kind::Circle => {
448                radius(matches!(edge(c)?.1, EdgeShape::Circle { full: true }))
449            }
450            _ => distance(Along::Aligned),
451        },
452        Tool::Horizontal => distance(Along::Horizontal),
453        Tool::Vertical => distance(Along::Vertical),
454        Tool::Radius => radius(false),
455        Tool::Diameter => radius(true),
456        Tool::Angle => Some(Annotation::Angle {
457            view,
458            a: edge(&picks[0])?.0,
459            b: edge(&picks[1])?.0,
460            label: [0.0, 0.0],
461        }),
462        Tool::CenterMark => Some(Annotation::CenterMark {
463            view,
464            edge: edge(&picks[0])?.0,
465        }),
466        Tool::Note => {
467            let target = match &picks[0].what {
468                Pickable::Point { target, .. } => target.clone(),
469                Pickable::Edge { name, .. } => Target::Edge { name: name.clone() },
470            };
471            Some(Annotation::Note {
472                text: String::new(),
473                leader: Some(Leader { view, target }),
474                label: [0.0, 0.0],
475            })
476        }
477    }
478}
479
480/// `annotation` with its value placed at `at` on the sheet, measured in
481/// `part` as `layout` draws it — or why it cannot be drawn.
482fn placed_at(
483    mut annotation: Annotation,
484    part: &Reader,
485    layout: &Layout,
486    at: P,
487) -> GeopResult<(Annotation, Drawn)> {
488    let anchor = annotation.drawn(part, layout)?.anchor;
489    if let Some(label) = annotation.label_mut() {
490        *label = sub(at, anchor);
491    }
492    let drawn = annotation.drawn(part, layout)?;
493    Ok((annotation, drawn))
494}
495
496/// How a candidate is drawn, and hit.
497fn candidate_shape(c: &Candidate) -> Shape {
498    match &c.what {
499        Pickable::Point { at, .. } => Shape::Point { at: world(*at) },
500        Pickable::Edge { points, .. } => Shape::Polyline {
501            points: points.iter().map(|&p| world(p)).collect(),
502        },
503    }
504}
505
506/// The candidate of `layout` under `pointer` that `tool` can take next,
507/// with `picks` picked: one that adds to them, or starts afresh.
508fn under<'l>(
509    layout: &'l Layout,
510    pointer: &Pointer,
511    tool: Tool,
512    picks: &[Candidate],
513) -> Option<&'l Candidate> {
514    let takes = |c: &Candidate| {
515        let mut more: Vec<&Candidate> = picks.iter().collect();
516        more.push(c);
517        fits(tool, &more) || fits(tool, &[c])
518    };
519    let usable: Vec<&Candidate> = layout.candidates.iter().filter(|c| takes(c)).collect();
520    let visuals: Vec<Visual> = usable
521        .iter()
522        .enumerate()
523        .map(|(i, c)| Visual::new(i.to_string(), candidate_shape(c), Style::Free))
524        .collect();
525    let hit = hit_visuals(&visuals, pointer, no_instances, |_| true)?;
526    usable.get(hit.visual.key.parse::<usize>().ok()?).copied()
527}
528
529/// The style a layer's strokes are shown in: the part's lines as a
530/// sketch's fixed geometry, hidden lines as construction, the rest as
531/// guides.
532fn style(on: Layer) -> Style {
533    if [layer::VISIBLE, layer::BORDER, layer::THREAD].contains(&on) {
534        Style::Fixed
535    } else if on == layer::HIDDEN {
536        Style::Construction
537    } else {
538        Style::Guide
539    }
540}
541
542/// Points every circle a stroke draws is shown through.
543const CIRCLE_POINTS: usize = 48;
544
545/// The sheet's strokes and texts as visuals, under keys starting `key`.
546fn sheet_visuals(sheet: &Sheet, key: &str, out: &mut Vec<Visual>) {
547    use geop_lib_sheet::Shape as Stroke;
548    let arc = |center: P, radius: f64, start: f64, sweep: f64| -> Vec<Vector3> {
549        let n = ((CIRCLE_POINTS as f64 * sweep / std::f64::consts::TAU).ceil() as usize).max(2);
550        (0..=n)
551            .map(|k| {
552                let a = start + sweep * k as f64 / n as f64;
553                world([center[0] + radius * a.cos(), center[1] + radius * a.sin()])
554            })
555            .collect()
556    };
557    for (i, stroke) in sheet.strokes.iter().enumerate() {
558        let shape = match &stroke.shape {
559            Stroke::Line(a, b) => Shape::Polyline {
560                points: vec![world(*a), world(*b)],
561            },
562            Stroke::Polyline(points) => Shape::Polyline {
563                points: points.iter().map(|&p| world(p)).collect(),
564            },
565            Stroke::Circle { center, radius } => Shape::Polyline {
566                points: arc(*center, *radius, 0.0, std::f64::consts::TAU),
567            },
568            Stroke::Arc {
569                center,
570                radius,
571                start,
572                end,
573            } => Shape::Polyline {
574                points: arc(
575                    *center,
576                    *radius,
577                    *start,
578                    (end - start).rem_euclid(std::f64::consts::TAU),
579                ),
580            },
581            Stroke::Filled(points) => Shape::Triangles {
582                triangles: (1..points.len().saturating_sub(1))
583                    .map(|k| [points[0], points[k], points[k + 1]].map(world))
584                    .collect(),
585            },
586        };
587        out.push(Visual::new(
588            format!("{key}/{i}"),
589            shape,
590            style(stroke.layer),
591        ));
592    }
593    for (i, text) in sheet.labels.iter().enumerate() {
594        // Shown centred where it is written: an average glyph is about
595        // 0.6 of the text's height wide.
596        let half = 0.3 * text.height * text.text.chars().count() as f64;
597        let along = match text.anchor {
598            Anchor::Start => half,
599            Anchor::Middle => 0.0,
600            Anchor::End => -half,
601        };
602        let (s, c) = text.angle.to_radians().sin_cos();
603        let up = text.height / 2.0;
604        let at = [
605            text.at[0] + along * c - up * s,
606            text.at[1] + along * s + up * c,
607        ];
608        // A dimension's value as a sketch shows one; the title block's,
609        // the parts list's and the views' captions as plain text.
610        let style = if text.layer == layer::DIMENSION {
611            Style::Fixed
612        } else {
613            Style::Paper
614        };
615        out.push(Visual::new(
616            format!("{key}/text{i}"),
617            Shape::Label {
618                at: world(at),
619                text: text.text.clone(),
620                offset: Vector3::zero(),
621            },
622            style,
623        ));
624    }
625}
626
627/// An annotation as visuals: its lines and arrowheads under `key/…`, in
628/// `style`, and its value under `key`, in `value` — or, for a centre mark,
629/// which has none, its first line. What is under `key` is what selects it
630/// and drags it.
631fn annotation_visuals(drawn: &Drawn, key: &str, style: Style, value: Style) -> Vec<Visual> {
632    let mut out = Vec::new();
633    for (i, (_, line)) in drawn.lines.iter().enumerate() {
634        let shape = Shape::Polyline {
635            points: line.iter().map(|&p| world(p)).collect(),
636        };
637        out.push(match (i, &drawn.text) {
638            (0, None) => Visual::new(key, shape, style),
639            _ => Visual::new(format!("{key}/{i}"), shape, style),
640        });
641    }
642    for (i, arrow) in drawn.arrows.iter().enumerate() {
643        out.push(Visual::new(
644            format!("{key}/arrow{i}"),
645            Shape::Triangles {
646                triangles: vec![arrow.map(world)],
647            },
648            style,
649        ));
650    }
651    if let Some(text) = &drawn.text {
652        out.push(Visual::new(
653            key,
654            Shape::Label {
655                at: world(drawn.label),
656                text: text.text.clone(),
657                offset: Vector3::zero(),
658            },
659            value,
660        ));
661    }
662    out
663}
664
665/// What the tool in hand asks for next.
666fn hint(s: &DrawingSession) -> String {
667    let complete = build(s.tool, &s.picks).is_some();
668    match s.tool {
669        Tool::Select => "Take up a tool to add dimensions, notes and centre marks · drag a value \
670                         to move it · Delete removes what is selected"
671            .into(),
672        Tool::Note if s.picks.is_empty() => {
673            "Note: click what it points at, or empty paper to put it there · Esc to stop".into()
674        }
675        Tool::Note => "Click where the note's text goes · Esc to start again".into(),
676        Tool::CenterMark => "Click a circle the view sees round · Esc to stop".into(),
677        _ if complete => "Click where its value goes · Esc to start again".into(),
678        tool => format!("{}: click it in a view · Esc to stop", tool.label()),
679    }
680}
681
682/// The fields and visuals of annotating `layout`'s sheet of `part`: the
683/// tools, what they ask for, the annotations — each with its value, or why
684/// it cannot be drawn — and the sheet itself in the viewport, with what
685/// is picked, what a click would pick, and the annotation being placed.
686fn annotate(
687    f: &mut EditForm<'_, DrawingArgs, DrawingSession>,
688    part: &Reader,
689    args: &DrawingArgs,
690    s: &DrawingSession,
691    selection: &[String],
692    layout: &Layout,
693) {
694    let sheet = &layout.sheet;
695    f.form.focus = Some(paper());
696    f.form.sheet = Some(Extent {
697        center: world([sheet.width / 2.0, sheet.height / 2.0]),
698        size: Scalar::from_f64(sheet.width.hypot(sheet.height)),
699    });
700    f.form.tool = match s.tool {
701        Tool::Select => InHand::Nothing,
702        _ => InHand::Clicks,
703    };
704    sheet_visuals(sheet, "sheet", &mut f.form.visuals);
705
706    f.form.dialog.heading("annotate_heading", "Annotate");
707    let actions = TOOLS
708        .iter()
709        .map(|&(tool, name, label, icon)| {
710            Action::new(name, label).icon(icon).active(s.tool == tool)
711        })
712        .collect();
713    f.actions("tools", actions, |edit, name| {
714        let s = edit.session;
715        let tool = Tool::by_name(name).unwrap_or_default();
716        s.tool = if s.tool == tool { Tool::Select } else { tool };
717        s.picks.clear();
718        s.hover = None;
719        s.refused = None;
720    });
721    f.form.dialog.text("tool_hint", hint(s), Tone::Hint);
722    if let Some(why) = &s.refused {
723        (f.form.dialog).text("refused", format!("Not added: {why}"), Tone::Error);
724    }
725    if s.tool == Tool::Note {
726        f.input("note_text", "Text", &s.note, None, |edit, text| {
727            edit.session.note = text.to_string();
728        });
729    }
730
731    let mut items = Vec::new();
732    for (i, annotation) in args.annotations.iter().enumerate() {
733        let key = annotation_key(i);
734        let mut item = ListItem::new(format!("annotation:{i}"), annotation.kind());
735        item.removable = true;
736        item.selected = selection.contains(&key);
737        match annotation.drawn(part, layout) {
738            Ok(drawn) => {
739                let value = drawn.text.as_ref().map(|t| t.text.clone());
740                let view = annotation.view().map(|v| v.name().to_string());
741                item.detail = match (value, view) {
742                    (Some(value), Some(view)) => Some(format!("{value} · {view}")),
743                    (value, view) => value.or(view),
744                };
745                let style = if item.selected {
746                    Style::Selected
747                } else {
748                    Style::Guide
749                };
750                // Its value — or a centre mark's line — is selected and
751                // dragged, unless a tool is in hand.
752                f.form.visuals.extend(
753                    annotation_visuals(&drawn, &key, style, Style::Fixed)
754                        .into_iter()
755                        .map(|v| match v.key == key {
756                            true => v.selectable().draggable_from_grab(),
757                            false => v,
758                        }),
759                );
760            }
761            Err(e) => {
762                item.tone = Tone::Error;
763                item.detail = Some(e.root_message().to_string());
764            }
765        }
766        if let Annotation::Note { text, .. } = annotation {
767            item.text = Some(text.clone());
768        }
769        items.push(item);
770        f.on(format!("annotation:{i}"), move |edit, value| match value {
771            Value::Remove if i < edit.args.annotations.len() => {
772                edit.args.annotations.remove(i);
773                edit.selection.clear();
774            }
775            Value::Text(text) => {
776                if let Some(Annotation::Note { text: t, .. }) = edit.args.annotations.get_mut(i) {
777                    *t = text;
778                }
779            }
780            Value::Press => *edit.selection = vec![annotation_key(i)],
781            _ => {}
782        });
783    }
784    f.form.dialog.list(
785        "annotations",
786        items,
787        "None yet: the views show their overall sizes.",
788    );
789
790    // What is picked, what a click would pick, and what it would place.
791    for (k, pick) in s.picks.iter().enumerate() {
792        f.form.visuals.push(Visual::new(
793            format!("picked{k}"),
794            candidate_shape(pick),
795            Style::Selected,
796        ));
797    }
798    if let Some(hover) = &s.hover {
799        (f.form.visuals).push(Visual::new("hover", candidate_shape(hover), Style::Hover));
800    }
801    if let (Some(annotation), Some(cursor)) = (build(s.tool, &s.picks), s.cursor)
802        && let Ok((_, drawn)) = placed_at(annotation, part, layout, cursor)
803    {
804        f.form.visuals.extend(annotation_visuals(
805            &drawn,
806            "placing",
807            Style::Draft,
808            Style::Draft,
809        ));
810    }
811}
812
813impl DrawingSession {
814    /// A click on the sheet at `at`, with the tool in hand: what is under
815    /// the pointer — `hit` — picked for it, or, once it has all it needs,
816    /// what it makes added to `args`, its value at `at`.
817    fn click(
818        &mut self,
819        args: &mut DrawingArgs,
820        part: &Reader,
821        layout: &Layout,
822        at: P,
823        hit: Option<Candidate>,
824    ) {
825        self.refused = None;
826        let mut more: Vec<&Candidate> = self.picks.iter().collect();
827        let grows = hit.as_ref().is_some_and(|h| {
828            more.push(h);
829            fits(self.tool, &more) && (self.tool != Tool::Note || self.picks.is_empty())
830        });
831        if grows && let Some(hit) = &hit {
832            self.picks.push(hit.clone());
833            // A centre mark has nothing to place: it is added at once.
834            if self.tool == Tool::CenterMark {
835                self.add(args, part, layout, at);
836            }
837            return;
838        }
839        if self.tool == Tool::Note && self.picks.is_empty() {
840            // Empty paper: the note goes there, pointing at nothing.
841            args.annotations.push(Annotation::Note {
842                text: self.note.clone(),
843                leader: None,
844                label: at,
845            });
846            return;
847        }
848        if build(self.tool, &self.picks).is_some() {
849            self.add(args, part, layout, at);
850            return;
851        }
852        // What was clicked cannot be part of it: it starts afresh.
853        self.picks = hit.filter(|h| fits(self.tool, &[h])).into_iter().collect();
854    }
855
856    /// Adds what the tool makes of the picks, its value at `at`.
857    fn add(&mut self, args: &mut DrawingArgs, part: &Reader, layout: &Layout, at: P) {
858        let Some(mut annotation) = build(self.tool, &self.picks) else {
859            return;
860        };
861        if let Annotation::Note { text, .. } = &mut annotation {
862            *text = self.note.clone();
863        }
864        self.picks.clear();
865        match placed_at(annotation, part, layout, at) {
866            Ok((annotation, _)) => args.annotations.push(annotation),
867            Err(e) => self.refused = Some(e.root_message().to_string()),
868        }
869    }
870}
871
872/// Whether the drawing `args` describe has the view `view`.
873fn shows(args: &DrawingArgs, view: DrawnView) -> bool {
874    match view {
875        DrawnView::View(kind) => args.views.contains(&kind),
876        DrawnView::Section => args.section.is_some(),
877    }
878}
879
880/// Checks that `target` names something in `part`.
881fn resolve_target(part: &Reader, target: &Target) -> GeopResult<()> {
882    match target {
883        Target::Vertex { name } => placed_vertex(part, name).map(|_| ()),
884        Target::Edge { name } => placed_edge(part, name).map(|_| ()),
885        Target::Center { edge } => circular(part, edge),
886    }
887}
888
889/// Checks that `edge` is a circular edge of `part`.
890fn circular(part: &Reader, edge: &str) -> GeopResult<()> {
891    match arc_of(&placed_edge(part, edge)?)? {
892        Some(_) => Ok(()),
893        None => Err(GeopError::new(format!(
894            "the edge {edge} is not circular: only a circle has a radius or a centre"
895        ))),
896    }
897}
898
899/// Checks that `edge` is a straight edge of `part`.
900fn straight(part: &Reader, edge: &str) -> GeopResult<()> {
901    match placed_edge(part, edge)?.as_line()? {
902        Some(_) => Ok(()),
903        None => Err(GeopError::new(format!(
904            "the edge {edge} is not straight: only straight edges make an angle"
905        ))),
906    }
907}
908
909/// Checks that what the drawing `args` describe refers to is there in the
910/// part `part` shows — its section plane, and what each annotation names,
911/// in a view the drawing has. Whether each view sees what it is asked to
912/// measure is checked when the drawing is laid out.
913fn check(part: &Reader, step: &str, args: &DrawingArgs) -> GeopResult<()> {
914    let ctx = with_context!("drawing({step})");
915    if args.views.is_empty() && args.section.is_none() {
916        return Err(GeopError::new("a drawing needs a view")).with_context(ctx);
917    }
918    if let Some(plane) = &args.section {
919        plane.resolve(part).with_context(ctx)?;
920    }
921    if let Some(scale) = args.scale
922        && !(scale.is_finite() && scale > 0.0)
923    {
924        return Err(GeopError::new(format!(
925            "the scale {scale} is not a positive number"
926        )))
927        .with_context(ctx);
928    }
929    for (index, annotation) in args.annotations.iter().enumerate() {
930        let ctx = |e: GeopError| {
931            e.with_context(format!(
932                "drawing({step}) annotation {}: {}",
933                index + 1,
934                annotation.describe()
935            ))
936        };
937        if let Some(view) = annotation.view()
938            && !shows(args, view)
939        {
940            return Err(GeopError::new(format!(
941                "the drawing has no {view} view any more: show it again, or remove the {}",
942                annotation.kind().to_lowercase()
943            )))
944            .with_context(&ctx);
945        }
946        match annotation {
947            Annotation::Distance { from, to, .. } => {
948                resolve_target(part, from).with_context(&ctx)?;
949                resolve_target(part, to).with_context(&ctx)?;
950            }
951            Annotation::Radius { edge, .. } | Annotation::CenterMark { edge, .. } => {
952                circular(part, edge).with_context(&ctx)?;
953            }
954            Annotation::Angle { a, b, .. } => {
955                straight(part, a).with_context(&ctx)?;
956                straight(part, b).with_context(&ctx)?;
957            }
958            Annotation::Note { leader, .. } => {
959                if let Some(leader) = leader {
960                    resolve_target(part, &leader.target).with_context(&ctx)?;
961                }
962            }
963        }
964    }
965    Ok(())
966}
967
968part_operation!(Drawing(DrawingArgs));
969
970impl PartOperation for Drawing {
971    const KIND: &'static str = "drawing";
972    const INFO: OperationInfo = OperationInfo::new(
973        "Drawing",
974        "A 2-D drawing of the part: views with hidden lines, a section, dimensions, notes and a title block — downloaded as SVG or DXF.",
975        OperationGroup::Output,
976        OperationTier::Big,
977    )
978    .shortcut("Shift+D")
979    .icon(crate::icons::DRAWING);
980    type Args = DrawingArgs;
981    type Session = DrawingSession;
982
983    /// Front, top, right and isometric views, third angle, on A3.
984    fn new_args(_: &Context) -> DrawingArgs {
985        DrawingArgs::default()
986    }
987
988    /// Records the drawing (see [`drawing`]), once what it refers to is
989    /// checked there in the part before it; it changes nothing else.
990    fn run(ctx: &mut StepContext, args: &DrawingArgs) -> GeopResult<()> {
991        let step = ctx.step();
992        check(&ctx.reader(), step, args)?;
993        ctx.produce(&drawing(step), args, described)
994    }
995
996    /// The form of the drawing `args` of the part `context` is edited against:
997    /// its download, its sheet to annotate, its views and its title block.
998    fn form<'a>(
999        context: &'a Context<'a>,
1000        args: &DrawingArgs,
1001        editing: &DrawingEditing,
1002    ) -> EditForm<'a, DrawingArgs, DrawingSession> {
1003        let (session, selection) = (&editing.session, &editing.selection[..]);
1004        let part = &context.before;
1005        let mut f = EditForm::<DrawingArgs, DrawingSession>::default();
1006        f.form.selection = selection.to_vec();
1007        f.form.dialog.download(
1008            "download",
1009            <DrawingExporter as Exporter>::INFO.kind,
1010            vec![Choice::new("svg", "SVG"), Choice::new("dxf", "DXF")],
1011        );
1012        match &*session.layout(part, &context.runner.location.file, args) {
1013            Ok(layout) => annotate(&mut f, part, args, session, selection, layout),
1014            Err(e) => {
1015                f.form.dialog.text(
1016                    "sheet_error",
1017                    format!("The sheet cannot be laid out: {e}"),
1018                    Tone::Error,
1019                );
1020            }
1021        }
1022
1023        f.form.dialog.heading("views_heading", "Views");
1024        for kind in ViewKind::ALL {
1025            f.checkbox(
1026                &format!("view:{}", kind.name()),
1027                label(kind),
1028                args.views.contains(&kind),
1029                move |Edit { args, .. }, on| {
1030                    args.views.retain(|k| *k != kind);
1031                    if on {
1032                        args.views.push(kind);
1033                        args.views
1034                            .sort_by_key(|k| ViewKind::ALL.iter().position(|a| a == k));
1035                    }
1036                },
1037            );
1038        }
1039        f.select(
1040            "projection",
1041            "projection",
1042            match args.projection {
1043                Projection::ThirdAngle => "third_angle",
1044                Projection::FirstAngle => "first_angle",
1045            },
1046            vec![
1047                Choice::new("third_angle", "Third angle"),
1048                Choice::new("first_angle", "First angle"),
1049            ],
1050            false,
1051            |Edit { args, .. }, value| {
1052                args.projection = match value {
1053                    "first_angle" => Projection::FirstAngle,
1054                    _ => Projection::ThirdAngle,
1055                }
1056            },
1057        );
1058        f.checkbox(
1059            "hidden_lines",
1060            "Hidden lines",
1061            args.hidden_lines,
1062            |Edit { args, .. }, on| args.hidden_lines = on,
1063        );
1064        f.checkbox(
1065            "tangent_edges",
1066            "Tangent edges",
1067            args.tangent_edges,
1068            |Edit { args, .. }, on| args.tangent_edges = on,
1069        );
1070        f.pick(
1071            "section",
1072            "section plane",
1073            part,
1074            args.section.as_slice(),
1075            false,
1076            |edit, picked| edit.args.section = picked.into_iter().next_back(),
1077        );
1078        f.form.dialog.optional("section");
1079
1080        f.form.dialog.heading("sheet_heading", "Sheet");
1081        f.select(
1082            "sheet",
1083            "paper",
1084            args.sheet.name(),
1085            SheetSize::ALL
1086                .iter()
1087                .map(|s| Choice::new(s.name(), s.name().to_uppercase()))
1088                .collect(),
1089            false,
1090            |Edit { args, .. }, value| {
1091                if let Some(s) = SheetSize::ALL.into_iter().find(|s| s.name() == value) {
1092                    args.sheet = s;
1093                }
1094            },
1095        );
1096        let mut scales = vec![Choice::new("fit", "To fit")];
1097        scales.extend(
1098            SCALES
1099                .iter()
1100                .map(|&s| Choice::new(scale_label(s), scale_label(s))),
1101        );
1102        f.select(
1103            "scale",
1104            "scale",
1105            args.scale.map(scale_label).unwrap_or_else(|| "fit".into()),
1106            scales,
1107            false,
1108            |Edit { args, .. }, value| {
1109                args.scale = SCALES.iter().copied().find(|&s| scale_label(s) == value);
1110            },
1111        );
1112        f.checkbox(
1113            "bom",
1114            "Bill of materials",
1115            args.bom,
1116            |Edit { args, .. }, on| args.bom = on,
1117        );
1118        f.input("title:name", "Part name", &args.name, None, |edit, text| {
1119            edit.args.name = text.to_string();
1120        });
1121        f.input(
1122            "title:material",
1123            "Material",
1124            &args.material,
1125            None,
1126            |edit, text| {
1127                edit.args.material = text.to_string();
1128            },
1129        );
1130        f
1131    }
1132
1133    /// The pointer on the sheet of the part `context` is edited against: a
1134    /// tool picks what the views show and puts down what it makes; a value is
1135    /// dragged where it goes; Escape lets go of the picks, then of the tool;
1136    /// Delete removes the annotations selected.
1137    fn canvas(context: &Context, edit: Edit<'_, DrawingArgs, DrawingSession>, event: &CanvasEvent) {
1138        let part = &context.before;
1139        let Edit {
1140            args,
1141            session: s,
1142            selection,
1143        } = edit;
1144        let laid = s.layout(part, &context.runner.location.file, args);
1145        let Ok(layout) = &*laid else {
1146            return;
1147        };
1148        match event {
1149            CanvasEvent::Hover { pointer, .. } => {
1150                s.cursor = on_sheet(pointer);
1151                s.hover = match s.tool {
1152                    Tool::Select => None,
1153                    tool => under(layout, pointer, tool, &s.picks).cloned(),
1154                };
1155            }
1156            CanvasEvent::Leave => {
1157                s.cursor = None;
1158                s.hover = None;
1159            }
1160            CanvasEvent::Click {
1161                pointer,
1162                double: false,
1163                ..
1164            } if s.tool != Tool::Select => {
1165                let Some(at) = on_sheet(pointer) else {
1166                    return;
1167                };
1168                let hit = under(layout, pointer, s.tool, &s.picks).cloned();
1169                s.click(args, part, layout, at, hit);
1170                s.hover = under(layout, pointer, s.tool, &s.picks).cloned();
1171            }
1172            CanvasEvent::Confirm => s.picks.clear(),
1173            // Each event of a drag reaches the annotation as it was when grabbed
1174            // (see `Visual::from_grab`).
1175            CanvasEvent::Move { key, from, to, .. } => {
1176                let Some(i) = annotation_of(key).filter(|_| !key.contains('/')) else {
1177                    return;
1178                };
1179                let Some(annotation) = args.annotations.get_mut(i) else {
1180                    return;
1181                };
1182                let moved = [
1183                    to[0].to_f64() - from[0].to_f64(),
1184                    to[1].to_f64() - from[1].to_f64(),
1185                ];
1186                if let Some(label) = annotation.label_mut() {
1187                    *label = add(*label, moved);
1188                }
1189            }
1190            CanvasEvent::Cancel => {
1191                s.refused = None;
1192                if s.picks.is_empty() {
1193                    s.tool = Tool::Select;
1194                }
1195                s.picks.clear();
1196                s.hover = None;
1197            }
1198            CanvasEvent::Delete => {
1199                let mut gone: Vec<usize> =
1200                    selection.iter().filter_map(|k| annotation_of(k)).collect();
1201                gone.sort_unstable();
1202                gone.dedup();
1203                for i in gone.into_iter().rev() {
1204                    if i < args.annotations.len() {
1205                        args.annotations.remove(i);
1206                    }
1207                }
1208                selection.clear();
1209            }
1210            _ => {}
1211        }
1212    }
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217    use super::*;
1218
1219    const FRONT: DrawnView = DrawnView::View(ViewKind::Front);
1220
1221    fn point(view: DrawnView, name: &str) -> Candidate {
1222        Candidate {
1223            view,
1224            what: Pickable::Point {
1225                target: Target::Vertex { name: name.into() },
1226                at: [0.0, 0.0],
1227            },
1228        }
1229    }
1230
1231    fn edge(view: DrawnView, name: &str, shape: EdgeShape) -> Candidate {
1232        Candidate {
1233            view,
1234            what: Pickable::Edge {
1235                name: name.into(),
1236                points: Vec::new(),
1237                shape,
1238            },
1239        }
1240    }
1241
1242    fn line(name: &str) -> Candidate {
1243        let shape = EdgeShape::Line {
1244            start: format!("{name}.start"),
1245            end: format!("{name}.end"),
1246        };
1247        edge(FRONT, name, shape)
1248    }
1249
1250    /// The dimension tool makes what its picks call for: two points their
1251    /// distance, a line its length, two lines their angle, a circle its
1252    /// diameter and an arc its radius — and takes nothing from two views,
1253    /// or from the isometric view, which foreshortens what it shows.
1254    #[test]
1255    fn the_dimension_tool_makes_what_its_picks_call_for() {
1256        let made = |picks: &[Candidate]| build(Tool::Dimension, picks);
1257        assert!(matches!(
1258            made(&[point(FRONT, "a"), point(FRONT, "b")]),
1259            Some(Annotation::Distance {
1260                along: Along::Aligned,
1261                ..
1262            })
1263        ));
1264        let Some(Annotation::Distance { from, to, .. }) = made(&[line("l")]) else {
1265            panic!("a line's length");
1266        };
1267        assert_eq!(
1268            (from.to_string(), to.to_string()),
1269            ("l.start".into(), "l.end".into())
1270        );
1271        assert!(matches!(
1272            made(&[line("l"), line("m")]),
1273            Some(Annotation::Angle { .. })
1274        ));
1275        let round = |full| edge(FRONT, "c", EdgeShape::Circle { full });
1276        assert!(matches!(
1277            made(&[round(true)]),
1278            Some(Annotation::Radius { diameter: true, .. })
1279        ));
1280        assert!(matches!(
1281            made(&[round(false)]),
1282            Some(Annotation::Radius {
1283                diameter: false,
1284                ..
1285            })
1286        ));
1287        assert!(
1288            made(&[point(FRONT, "a")]).is_none(),
1289            "one point is not enough"
1290        );
1291        let top = DrawnView::View(ViewKind::Top);
1292        assert!(!fits(
1293            Tool::Dimension,
1294            &[&point(FRONT, "a"), &point(top, "b")]
1295        ));
1296        let iso = DrawnView::View(ViewKind::Iso);
1297        assert!(!fits(Tool::Dimension, &[&point(iso, "a")]));
1298        assert!(
1299            fits(Tool::Note, &[&point(iso, "a")]),
1300            "a note points into any view"
1301        );
1302        assert!(matches!(
1303            build(Tool::Horizontal, &[line("l")]),
1304            Some(Annotation::Distance {
1305                along: Along::Horizontal,
1306                ..
1307            })
1308        ));
1309    }
1310}