1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
64pub struct Drawing;
65
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
68pub enum Tool {
69 #[default]
71 Select,
72 Dimension,
76 Horizontal,
77 Vertical,
78 Radius,
79 Diameter,
80 Angle,
81 Note,
82 CenterMark,
83}
84
85const 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#[derive(Clone)]
152pub struct DrawingSession {
153 tool: Tool,
154 picks: Vec<Candidate>,
156 hover: Option<Candidate>,
158 cursor: Option<P>,
160 note: String,
162 refused: Option<String>,
164 projected: Remembered<Result<Projected, String>>,
167 laid_out: Remembered<Result<Layout, String>>,
170}
171
172pub type DrawingEditing = FormState<DrawingSession>;
174
175type Remembered<T> = RefCell<Option<(Shown, DrawingArgs, Rc<T>)>>;
178
179#[derive(Clone)]
185struct Shown {
186 model: Option<NamedModel>,
187 threads: Option<Threads>,
188 properties: Properties,
189 placed: Vec<(String, u64, Pose)>,
192}
193
194impl Shown {
195 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
216impl 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 pub fn tool(&self) -> Tool {
246 self.tool
247 }
248
249 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 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
312fn paper() -> CoordinateSystem {
315 CoordinateSystem::world_at(Vector3::zero())
316}
317
318fn world(p: P) -> Vector3 {
320 Vector3::from_array([Scalar::from_f64(p[0]), Scalar::from_f64(p[1]), Scalar::ZERO])
321}
322
323fn 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
330fn annotation_key(i: usize) -> String {
333 format!("annotation{i}")
334}
335
336fn annotation_of(key: &str) -> Option<usize> {
338 key.strip_prefix("annotation")?
339 .split('/')
340 .next()?
341 .parse()
342 .ok()
343}
344
345#[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
365fn 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
379fn 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
395fn 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
480fn 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
496fn 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
506fn 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
529fn 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
542const CIRCLE_POINTS: usize = 48;
544
545fn 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 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 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
627fn 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
665fn 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
682fn 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 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 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 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 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 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 self.picks = hit.filter(|h| fits(self.tool, &[h])).into_iter().collect();
854 }
855
856 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
872fn 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
880fn 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
889fn 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
899fn 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
909fn 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 fn new_args(_: &Context) -> DrawingArgs {
985 DrawingArgs::default()
986 }
987
988 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 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 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 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 #[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}