1use geop_core_editor::{
7 edit_form::{EditForm, FormState, choices, chosen},
8 editable::{Context, OperationGroup, OperationInfo, OperationTier},
9 editing::{Choice, Number, Shape, Style, Tone, Unit, Visual},
10 part_operation,
11 part_operation::PartOperation,
12};
13use geop_core_program::{
14 element::ElementReference,
15 part::StepContext,
16 reader::Reader,
17 target::{Target, TargetReference},
18};
19
20use geop_core_geometry::shape::RoundWall;
21use geop_core_math::{
22 geop_error::{GeopError, GeopResult, WithContext},
23 primitives::CoordinateSystem,
24 scalars::Scalar,
25 vector::Vector3,
26 with_context,
27};
28use geop_core_topology::SolidId;
29use geop_lib_booleans::{Reach, combine_tools};
30use geop_ops_model::spacing::{
31 HoleFit, HoleSite, HoleSites, PointsRef, hole_sites, read_hole_sites, resolve_points,
32};
33use geop_ops_model::{Combine, Extent, Tool, hull, reach_past};
34use geop_ops_model::{
35 CosmeticThread, Face, ModelOperation, NamedModel, Namer, Plane, Threads, ToolReach,
36 build_model, draw_threads, frame_along, read_model, threads,
37};
38use serde::{Deserialize, Serialize};
39
40use crate::{
41 hole::{Head, HoleDepth, HoleShape, hole_tool},
42 iso::{self, Fit, metric},
43 pipe::{self, npt},
44 thread::{ThreadPlacement, ThreadSize, Wall, thread_tool},
45};
46
47pub type HoleCentre = PointsRef;
51
52pub type PlaneRef = ElementReference<NamedModel, Plane>;
54
55pub type FaceRef = ElementReference<NamedModel, Face>;
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum HoleKind {
62 #[default]
64 Simple,
65 Counterbore,
67 Countersink,
69 Tapped,
72}
73
74impl HoleKind {
75 const ALL: [(HoleKind, &'static str, &'static str); 4] = [
76 (HoleKind::Simple, "simple", "Simple"),
77 (HoleKind::Counterbore, "counterbore", "Counterbore"),
78 (HoleKind::Countersink, "countersink", "Countersink"),
79 (HoleKind::Tapped, "tapped", "Tapped"),
80 ];
81}
82
83#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum Standard {
89 Iso {
90 size: String,
91 #[serde(default)]
92 fit: Fit,
93 },
94 Npt {
95 size: String,
96 },
97 Custom {
100 diameter: f64,
101 #[serde(default)]
102 head_diameter: f64,
103 #[serde(default)]
104 head_depth: f64,
105 },
106}
107
108#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
126pub struct Hole;
127
128#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
129pub struct HoleArgs {
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub face: Option<PlaneRef>,
134 pub points: Vec<HoleCentre>,
136 pub kind: HoleKind,
137 pub standard: Standard,
138 pub end: Extent,
142 #[serde(default)]
144 pub drill_point: bool,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub thread_length: Option<f64>,
149}
150
151impl HoleArgs {
152 pub fn shape(&self) -> GeopResult<HoleShape> {
154 match &self.standard {
155 Standard::Iso { size, fit } => {
156 let size = metric(size)?;
157 let clearance = size.clearance(*fit);
158 Ok(match self.kind {
159 HoleKind::Simple => HoleShape {
160 diameter: clearance,
161 taper: 0.0,
162 head: Head::None,
163 thread: None,
164 },
165 HoleKind::Counterbore => HoleShape {
166 diameter: clearance,
167 taper: 0.0,
168 head: Head::Counterbore {
169 diameter: size.counterbore,
170 depth: size.diameter,
171 },
172 thread: None,
173 },
174 HoleKind::Countersink => HoleShape {
175 diameter: clearance,
176 taper: 0.0,
177 head: Head::Countersink {
178 diameter: size.countersink.ok_or_else(|| {
179 GeopError::new(format!(
180 "ISO 15065 gives no countersink for {}: it stops at M20",
181 size.name
182 ))
183 })?,
184 },
185 thread: None,
186 },
187 HoleKind::Tapped => HoleShape {
188 diameter: size.tap_drill,
189 taper: 0.0,
190 head: Head::None,
191 thread: Some(ThreadSize::Metric(size)),
192 },
193 })
194 }
195 Standard::Npt { size } => {
196 let size = npt(size)?;
197 if self.kind != HoleKind::Tapped {
198 return Err(GeopError::new(format!(
199 "an NPT size sizes a tapped hole for a pipe thread: make the hole tapped, or pick an ISO size for {}",
200 size.designation()
201 )));
202 }
203 let (_, minor) = size.diameters(true);
206 Ok(HoleShape {
207 diameter: minor,
208 taper: pipe::TAPER,
209 head: Head::None,
210 thread: Some(ThreadSize::Pipe(size)),
211 })
212 }
213 Standard::Custom {
214 diameter,
215 head_diameter,
216 head_depth,
217 } => {
218 let head = match self.kind {
219 HoleKind::Simple => Head::None,
220 HoleKind::Counterbore => Head::Counterbore {
221 diameter: *head_diameter,
222 depth: *head_depth,
223 },
224 HoleKind::Countersink => Head::Countersink {
225 diameter: *head_diameter,
226 },
227 HoleKind::Tapped => {
228 return Err(GeopError::new(
229 "a tapped hole is sized by its thread: pick an ISO or an NPT size",
230 ));
231 }
232 };
233 Ok(HoleShape {
234 diameter: *diameter,
235 taper: 0.0,
236 head,
237 thread: None,
238 })
239 }
240 }
241 }
242
243 fn describe(&self) -> GeopResult<String> {
245 let shape = self.shape()?;
246 let mut text = match shape.thread {
247 Some(size) if shape.taper > 0.0 => format!(
248 "reamed 1:{} from Ø{:.3}, thread {}",
249 1.0 / (2.0 * shape.taper),
250 shape.diameter,
251 size.designation()
252 ),
253 Some(size) => format!(
254 "tap drill Ø{}, thread {}",
255 shape.diameter,
256 size.designation()
257 ),
258 None => format!("Ø{}", shape.diameter),
259 };
260 match shape.head {
261 Head::None => {}
262 Head::Counterbore { diameter, depth } => {
263 text += &format!(", counterbore Ø{diameter} × {depth} deep")
264 }
265 Head::Countersink { diameter } => text += &format!(", countersink Ø{diameter} × 90°"),
266 }
267 Ok(text)
268 }
269}
270
271fn circle(centre: Vector3, normal: &Vector3, diameter: f64) -> Option<Shape> {
274 let frame = frame_along(centre, normal).ok()?;
275 let r = diameter / 2.0;
276 let points = (0..=48)
277 .map(|k| {
278 let a = k as f64 / 48.0 * std::f64::consts::TAU;
279 frame
280 .origin()
281 .add(&frame.u().prod_scalar(Scalar::from_f64(r * a.cos())))
282 .add(&frame.v().prod_scalar(Scalar::from_f64(r * a.sin())))
283 })
284 .collect();
285 Some(Shape::Polyline { points })
286}
287
288fn size_choices() -> Vec<Choice> {
290 iso::SIZES
291 .iter()
292 .map(|s| Choice::new(s.name, s.name))
293 .collect()
294}
295
296fn npt_choices() -> Vec<Choice> {
298 (pipe::SIZES.iter())
299 .map(|s| Choice::new(s.name, format!("{}\"", s.name)))
300 .collect()
301}
302
303fn thread_choices() -> Vec<Choice> {
306 (ThreadSize::all())
307 .map(|s| Choice::new(s.name(), s.name()))
308 .collect()
309}
310
311struct Sites {
315 target: String,
316 holes: Vec<(String, CoordinateSystem)>,
317}
318
319fn sites(reader: &Reader, args: &HoleArgs) -> GeopResult<Sites> {
321 let Some(face) = &args.face else {
322 return Err(GeopError::new("pick the planar face to drill into"));
323 };
324 let named = read_model(reader)?;
325 let face_name = face.name();
326 let face = named.face_id(face_name)?;
327 let plane = named.plane_of_face(face, "holes are drilled into a planar face")?;
328 let target = named.solid_name_of_face(face, "holes are drilled into a solid")?;
329 if args.points.is_empty() {
330 return Err(GeopError::new("pick the points to drill at"));
331 }
332 let mut holes = Vec::new();
333 for (scope, at) in resolve_points(reader, &args.points)? {
334 if !plane.signed_distance(&at).could_be_equal(Scalar::ZERO) {
335 return Err(GeopError::new(format!(
336 "the point {scope} does not lie on face {face_name:?}: holes are drilled at points on the face"
337 )));
338 }
339 holes.push((scope, frame_along(at, &plane.normal)?));
340 }
341 Ok(Sites { target, holes })
342}
343
344#[derive(Clone, Debug, PartialEq)]
349struct Drilled {
350 holes: Vec<(String, CoordinateSystem)>,
351 blind: Option<f64>,
353 threaded: Option<f64>,
354}
355
356impl Target for Drilled {}
358
359impl geop_core_program::element::Kind for Drilled {
361 const NAME: &'static str = "drilled";
362}
363
364impl geop_core_program::element::HasElements<Drilled> for Drilled {
365 type Element<'a> = &'a Drilled;
366
367 fn element(&self, name: &str) -> GeopResult<&Drilled> {
368 geop_core_program::element::whole(self, name)
369 }
370}
371
372fn drilled(step: &str) -> TargetReference<Drilled> {
374 TargetReference::new(format!("drilled:{step}"))
375}
376
377fn plan(reader: &Reader, args: &HoleArgs) -> GeopResult<Drilled> {
379 let blind = args.end.end(reader)?.length();
380 Ok(Drilled {
381 holes: sites(reader, args)?.holes,
382 blind,
383 threaded: args.thread_length.or(blind),
384 })
385}
386
387fn record_holes(reader: &Reader, (step, args): &(String, HoleArgs)) -> GeopResult<HoleSites> {
391 let mut sites = read_hole_sites(reader)?.clone();
392 let ctx = with_context!("the holes of hole({step})");
393 let Drilled { holes, blind, .. } =
394 reader.retrieve_element(&ElementReference::whole(drilled(step)))?;
395 let namer = Namer::new("hole", step)?;
396 let shape = args.shape().with_context(ctx)?;
397 let size = match &args.standard {
398 Standard::Iso { size, .. } => Some(size.clone()),
399 _ => None,
400 };
401 let fit = match args.kind {
402 HoleKind::Simple => HoleFit::Clearance,
403 HoleKind::Counterbore => HoleFit::Counterbore,
404 HoleKind::Countersink => HoleFit::Countersink,
405 HoleKind::Tapped => HoleFit::Tapped,
406 };
407 let seat = match shape.head {
408 Head::Counterbore { depth, .. } => depth,
409 _ => 0.0,
410 };
411 let model = read_model(reader).with_context(ctx)?;
412 let topology = model.topology();
413 for (scope, frame) in holes {
414 let direction = frame.w().neg();
415 let wall = namer.scoped(scope).name(&["wall"]);
416 let wall = format!("{},", wall.trim_end_matches(')'));
417 let mut deepest: Option<f64> = None;
419 for (&face, _) in &topology.faces {
420 if !model
421 .name_of(face)
422 .is_some_and(|name| name.starts_with(&wall))
423 {
424 continue;
425 }
426 for edge in topology.face_edges(face) {
427 let edge = topology.get_edge(edge).with_context(ctx)?;
428 for vertex in [edge.start_vertex, edge.end_vertex] {
429 let at = topology.get_vertex(vertex).with_context(ctx)?.point;
430 let along = at.sub(frame.origin()).prod_dot(&direction).upper().to_f64();
431 deepest = Some(deepest.map_or(along, |d| d.max(along)));
432 }
433 }
434 }
435 let depth = deepest.or(*blind).ok_or_else(|| {
436 GeopError::new(format!(
437 "the hole {scope} left no wall to measure how deep it is"
438 ))
439 })?;
440 let site = HoleSite {
441 axis: geop_core_geometry::shape::Axis {
442 point: *frame.origin(),
443 direction,
444 },
445 size: size.clone(),
446 fit,
447 seat,
448 depth,
449 through: blind.is_none(),
450 };
451 sites.add(namer.name(&[scope]), site).with_context(ctx)?;
452 }
453 Ok(sites)
454}
455
456fn tap(reader: &Reader, (step, args): &(String, HoleArgs)) -> GeopResult<Threads> {
459 let mut threads: Threads = reader.previous()?;
460 let ctx = with_context!("the threads of hole({step})");
461 let Drilled {
462 holes, threaded, ..
463 } = reader.retrieve_element(&ElementReference::whole(drilled(step)))?;
464 let namer = Namer::new("hole", step)?;
465 let shape = args.shape().with_context(ctx)?;
466 let Some(size) = shape.thread else {
467 return Ok(threads);
468 };
469 let model = read_model(reader).with_context(ctx)?;
470 let result = model.solid_id(&namer.root()).with_context(ctx)?;
471 for hole in holes {
472 let thread =
473 tapped_thread(&model, result, hole, &shape, &size, *threaded).with_context(ctx)?;
474 threads.add(namer.scoped(&hole.0).name(&["thread"]), thread)?;
475 }
476 Ok(threads)
477}
478
479part_operation!(Hole(HoleArgs), Thread(ThreadArgs));
480
481impl PartOperation for Hole {
482 const KIND: &'static str = "hole";
483 const INFO: OperationInfo = OperationInfo::new(
484 "Hole",
485 "Drill simple, counterbored, countersunk or tapped holes at points on a planar face, sized by ISO tables or by hand.",
486 OperationGroup::Features,
487 OperationTier::Big,
488 )
489 .shortcut("H")
490 .icon(crate::icons::HOLE);
491 type Args = HoleArgs;
492 type Session = ();
493
494 fn new_args(_: &Context) -> HoleArgs {
496 HoleArgs {
497 face: None,
498 points: Vec::new(),
499 kind: HoleKind::Simple,
500 standard: Standard::Iso {
501 size: "M6".into(),
502 fit: Fit::Normal,
503 },
504 end: Extent::blind(10.0),
505 drill_point: false,
506 thread_length: None,
507 }
508 }
509
510 fn run(ctx: &mut StepContext, args: &HoleArgs) -> GeopResult<()> {
514 let step = ctx.step();
515 let threaded = args.shape().is_ok_and(|shape| shape.thread.is_some());
516 let planned = ctx.produce(&drilled(step), args, plan);
517 build_model::<Self>(ctx, args)?;
518 planned?;
519 ctx.produce(
520 &hole_sites(),
521 &(step.to_string(), args.clone()),
522 record_holes,
523 )?;
524 match threaded {
525 true => {
526 ctx.produce(&threads(), &(step.to_string(), args.clone()), tap)?;
527 draw_threads(ctx)
528 }
529 false => Ok(()),
530 }
531 }
532
533 fn form<'a>(
537 context: &'a Context<'a>,
538 args: &HoleArgs,
539 _: &FormState<()>,
540 ) -> EditForm<'a, HoleArgs> {
541 let before = &context.before;
542 let mut f = EditForm::<HoleArgs>::default();
543 f.pick_one("face", "face", before, args.face.as_ref(), |edit, face| {
544 edit.args.face = Some(face)
545 });
546 f.pick(
547 "points",
548 "points",
549 before,
550 &args.points,
551 true,
552 |edit, picked| edit.args.points = picked,
553 );
554 let (kind, kinds) = choices(&HoleKind::ALL, &args.kind);
555 f.select("kind", "type", kind, kinds, false, |edit, value| {
556 if let Some(kind) = chosen(&HoleKind::ALL, value) {
557 edit.args.kind = kind;
558 }
559 });
560 let standard = match args.standard {
561 Standard::Iso { .. } => "iso",
562 Standard::Npt { .. } => "npt",
563 Standard::Custom { .. } => "custom",
564 };
565 let standards = vec![
566 Choice::new("iso", "ISO metric"),
567 Choice::new("npt", "NPT pipe thread"),
568 Choice::new("custom", "Custom"),
569 ];
570 f.select(
571 "standard",
572 "standard",
573 standard,
574 standards,
575 false,
576 |edit, value| {
577 let args = edit.args;
578 let shape = args.shape().ok();
579 match (value, &args.standard) {
580 ("custom", Standard::Iso { .. } | Standard::Npt { .. }) => {
581 let (head_diameter, head_depth) = match shape.map(|s| s.head) {
583 Some(Head::Counterbore { diameter, depth }) => (diameter, depth),
584 Some(Head::Countersink { diameter }) => (diameter, 0.0),
585 _ => (0.0, 0.0),
586 };
587 args.standard = Standard::Custom {
588 diameter: shape.map_or(6.0, |s| s.diameter),
589 head_diameter,
590 head_depth,
591 };
592 }
593 ("iso", Standard::Custom { .. } | Standard::Npt { .. }) => {
594 args.standard = Standard::Iso {
595 size: "M6".into(),
596 fit: Fit::Normal,
597 }
598 }
599 ("npt", Standard::Iso { .. } | Standard::Custom { .. }) => {
601 args.kind = HoleKind::Tapped;
602 args.standard = Standard::Npt { size: "1/2".into() };
603 }
604 _ => {}
605 }
606 },
607 );
608 match &args.standard {
609 Standard::Iso { size, fit } => {
610 f.select(
611 "size",
612 "size",
613 size.clone(),
614 size_choices(),
615 true,
616 |edit, value| {
617 if let Standard::Iso { size, .. } = &mut edit.args.standard {
618 *size = value.to_string();
619 }
620 },
621 );
622 if args.kind != HoleKind::Tapped {
623 let fits = Fit::ALL.iter().map(|f| Choice::new(f.value(), f.label()));
624 f.select(
625 "fit",
626 "fit",
627 fit.value(),
628 fits.collect(),
629 false,
630 |edit, value| {
631 if let (Standard::Iso { fit, .. }, Some(new)) =
632 (&mut edit.args.standard, Fit::from_value(value))
633 {
634 *fit = new;
635 }
636 },
637 );
638 }
639 }
640 Standard::Npt { size } => {
641 f.select(
642 "size",
643 "size",
644 size.clone(),
645 npt_choices(),
646 true,
647 |edit, value| {
648 if let Standard::Npt { size } = &mut edit.args.standard {
649 *size = value.to_string();
650 }
651 },
652 );
653 }
654 Standard::Custom {
655 diameter,
656 head_diameter,
657 head_depth,
658 } => {
659 let length = |label: &str, value: f64, max: f64| {
660 Number::new(label, value, Unit::Length).range(0.0, max)
661 };
662 f.number(
663 "diameter",
664 length("diameter", *diameter, 50.0),
665 |edit, v| {
666 if let Standard::Custom { diameter, .. } = &mut edit.args.standard {
667 *diameter = v;
668 }
669 },
670 );
671 if matches!(args.kind, HoleKind::Counterbore | HoleKind::Countersink) {
672 let number = length("head diameter", *head_diameter, 80.0);
673 f.number("head_diameter", number, |edit, v| {
674 if let Standard::Custom { head_diameter, .. } = &mut edit.args.standard {
675 *head_diameter = v;
676 }
677 });
678 }
679 if args.kind == HoleKind::Counterbore {
680 let number = length("head depth", *head_depth, 50.0);
681 f.number("head_depth", number, |edit, v| {
682 if let Standard::Custom { head_depth, .. } = &mut edit.args.standard {
683 *head_depth = v;
684 }
685 });
686 }
687 }
688 }
689 let dialog = &mut f.form.dialog;
690 match args.describe() {
691 Ok(text) => dialog.text("dimensions", text, Tone::Hint),
692 Err(e) => dialog.text("dimensions", e.root_message(), Tone::Error),
693 };
694 let end = Extent::mode(Some(&args.end));
695 f.select(
696 "end",
697 "end",
698 end,
699 Extent::choices(false),
700 false,
701 |edit, value| {
702 if let Some(end) = Extent::pick(Some(&edit.args.end), value, 10.0) {
703 edit.args.end = end;
704 }
705 },
706 );
707 if let Extent::Blind(depth) = &args.end {
708 let depth = Number::numeric(depth, "depth", before, Unit::Length);
709 f.numeric("depth", depth.range(0.0, 100.0), |edit, depth| {
710 edit.args.end = Extent::Blind(depth)
711 });
712 let label = "drill point (118°)";
713 f.checkbox("drill_point", label, args.drill_point, |edit, b| {
714 edit.args.drill_point = b
715 });
716 }
717 if args.kind == HoleKind::Tapped {
718 let full = (args.end.end(before).ok()).and_then(|end| end.length());
719 let length = args.thread_length.or(full);
720 let all = args.thread_length.is_none();
721 f.checkbox("full_thread", "thread all the way", all, |edit, b| {
722 let full = (edit.args.end.end(before).ok()).and_then(|end| end.length());
723 edit.args.thread_length = if b { None } else { Some(full.unwrap_or(10.0)) };
724 });
725 if let (Some(length), Some(_)) = (length, args.thread_length) {
726 let number = Number::new("thread length", length, Unit::Length).range(0.0, 100.0);
727 f.number("thread_length", number, |edit, l| {
728 edit.args.thread_length = Some(l)
729 });
730 }
731 }
732 let plane = (args.face.as_ref()).and_then(|face| before.retrieve_element(face).ok());
734 if let (Ok(shape), Some(plane)) = (args.shape(), plane)
735 && let Ok(centres) = resolve_points(before, &args.points)
736 {
737 let outer = match shape.head {
738 Head::None => shape.diameter,
739 Head::Counterbore { diameter, .. } | Head::Countersink { diameter } => diameter,
740 };
741 for (k, (_, at)) in centres.iter().enumerate() {
742 for (j, d) in [shape.diameter, outer].into_iter().enumerate() {
743 if j == 1 && d == shape.diameter {
744 continue;
745 }
746 if let Some(c) = circle(*at, plane.w(), d) {
747 let visual = Visual::new(format!("hole{k}.{j}"), c, Style::Draft);
748 f.form.visuals.push(visual);
749 }
750 }
751 }
752 }
753 f
754 }
755}
756
757fn tapped_thread(
764 model: &NamedModel,
765 solid: SolidId,
766 (scope, axes): &(String, CoordinateSystem),
767 shape: &HoleShape,
768 size: &ThreadSize,
769 length: Option<f64>,
770) -> GeopResult<CosmeticThread> {
771 let ctx = with_context!("the thread of the hole at {scope}");
772 let into = axes.w().neg();
773 let drilled = RoundWall {
774 axis: geop_core_geometry::shape::Axis::try_new(*axes.origin(), into)?,
775 radius: Scalar::from_f64(shape.diameter / 2.0),
776 taper: Scalar::from_f64(-shape.taper),
777 };
778 let wall = Wall::at(model, solid, &drilled, axes.origin())
779 .with_context(ctx)?
780 .ok_or_else(|| GeopError::new(format!("the hole at {scope} left no wall to thread")))?;
781 let entry = wall.round.along(axes.origin());
783 let placement = wall.placement((entry, into), length).with_context(ctx)?;
784 placement.check_fits(size, &wall.face_name(model))?;
785 placement.cosmetic(size, wall.face_name(model))
786}
787
788#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
803pub struct Thread;
804
805#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
806pub struct ThreadArgs {
807 #[serde(default, skip_serializing_if = "Option::is_none")]
810 pub face: Option<FaceRef>,
811 pub size: String,
813 #[serde(default, skip_serializing_if = "Option::is_none")]
815 pub length: Option<f64>,
816 #[serde(default)]
818 pub reversed: bool,
819 #[serde(default)]
821 pub modelled: bool,
822}
823
824impl ThreadArgs {
825 fn placement(&self, model: &NamedModel) -> GeopResult<(Wall, ThreadPlacement)> {
827 let wall = Wall::of(model, model.face_id(self.face_name()?)?)?;
828 let start = wall.start(self.reversed);
829 let placement = wall.placement(start, self.length)?;
830 Ok((wall, placement))
831 }
832
833 fn face_name(&self) -> GeopResult<&str> {
835 (self.face.as_ref())
836 .map(|face| face.name())
837 .ok_or_else(|| GeopError::new("pick the round face to thread"))
838 }
839}
840
841fn record(reader: &Reader, (step, args): &(String, ThreadArgs)) -> GeopResult<Threads> {
844 let mut threads: Threads = reader.previous()?;
845 let ctx = with_context!("thread({step}, {args:?})");
846 let namer = Namer::new("thread", step)?;
847 let size = ThreadSize::named(&args.size).with_context(ctx)?;
848 let model = read_model(reader).with_context(ctx)?;
849 let (_, placement) = args.placement(&model).with_context(ctx)?;
850 let face = args.face_name()?;
851 placement.check_fits(&size, face).with_context(ctx)?;
852 let thread = placement.cosmetic(&size, face.to_string())?;
853 threads.add(namer.root(), thread).with_context(ctx)?;
854 Ok(threads)
855}
856
857impl PartOperation for Thread {
858 const KIND: &'static str = "thread";
859 const INFO: OperationInfo = OperationInfo::new(
860 "Thread",
861 "Put an ISO metric or an NPT pipe thread on a round face: recorded as a cosmetic thread, or modelled.",
862 OperationGroup::Features,
863 OperationTier::Small,
864 )
865 .shortcut("T")
866 .icon(crate::icons::THREAD);
867 type Args = ThreadArgs;
868 type Session = ();
869
870 fn new_args(_: &Context) -> ThreadArgs {
872 ThreadArgs {
873 face: None,
874 size: "M6".into(),
875 length: None,
876 reversed: false,
877 modelled: false,
878 }
879 }
880
881 fn run(ctx: &mut StepContext, args: &ThreadArgs) -> GeopResult<()> {
884 let step = ctx.step();
885 if args.modelled {
886 build_model::<Self>(ctx, args)
887 } else {
888 ctx.produce(&threads(), &(step.to_string(), args.clone()), record)?;
889 draw_threads(ctx)
890 }
891 }
892
893 fn form<'a>(
896 context: &'a Context<'a>,
897 args: &ThreadArgs,
898 _: &FormState<()>,
899 ) -> EditForm<'a, ThreadArgs> {
900 let before = &context.before;
901 let model = read_model(before).unwrap_or_default();
902 let mut f = EditForm::<ThreadArgs>::default();
903 f.pick_one("face", "face", before, args.face.as_ref(), |edit, face| {
904 edit.args.face = Some(face)
905 });
906 f.select(
907 "size",
908 "size",
909 args.size.clone(),
910 thread_choices(),
911 true,
912 |edit, size| edit.args.size = size.to_string(),
913 );
914 let placed = args.placement(&model);
915 let internal = placed.as_ref().is_ok_and(|(wall, _)| wall.internal);
916 if let Ok(size) = ThreadSize::named(&args.size) {
917 let (major, minor) = size.diameters(internal);
918 let mut text = format!(
919 "{}: Ø{major:.3} major, Ø{minor:.3} minor",
920 size.designation(),
921 );
922 if size.taper() > 0.0 {
923 text += &format!(" at its start, tapering 1:{}", 1.0 / (2.0 * size.taper()));
924 }
925 f.form.dialog.text("dimensions", text, Tone::Hint);
926 }
927 let problem = match &placed {
928 Ok((wall, p)) => (ThreadSize::named(&args.size))
929 .and_then(|size| p.check_fits(&size, &wall.face_name(&model)))
930 .err()
931 .map(|e| e.root_message().to_string()),
932 Err(e) => Some(e.root_message().to_string()),
933 };
934 if let Some(problem) = problem
935 && args.face.is_some()
936 {
937 f.form.dialog.text("problem", problem, Tone::Error);
938 }
939 f.checkbox(
940 "full_length",
941 "whole face",
942 args.length.is_none(),
943 |edit, b| edit.args.length = if b { None } else { Some(10.0) },
944 );
945 if let Some(length) = args.length {
946 let number = Number::new("length", length, Unit::Length).range(0.0, 100.0);
947 f.number("length", number, |edit, l| edit.args.length = Some(l));
948 }
949 let label = "start from the other end";
950 f.checkbox("reversed", label, args.reversed, |edit, b| {
951 edit.args.reversed = b
952 });
953 f.checkbox("modelled", "modelled", args.modelled, |edit, b| {
954 edit.args.modelled = b
955 });
956 if let (Ok((_, placement)), Ok(size)) = (&placed, ThreadSize::named(&args.size))
957 && let Ok(face) = args.face_name()
958 && let Ok(thread) = placement.cosmetic(&size, face.to_string())
959 && let Ok(points) = thread.polyline()
960 {
961 let visual = Visual::new("helix", Shape::Polyline { points }, Style::Draft);
962 f.form.visuals.push(visual);
963 }
964 f
965 }
966}
967
968impl ModelOperation for Hole {
969 fn build(
970 model: &mut NamedModel,
971 reader: &Reader,
972 step: &str,
973 args: &HoleArgs,
974 ) -> GeopResult<()> {
975 let namer = Namer::new("hole", step)?;
976 let shape = args.shape()?;
977 let sites = sites(reader, args)?;
978 let blind = args.end.end(reader)?.length();
979 let target = sites.target;
980 let hull = match blind {
981 Some(_) => None,
982 None => hull(model, std::slice::from_ref(&target))?,
983 };
984 let mut tools = Vec::new();
985 for (scope, axes) in &sites.holes {
986 let depth = match (blind, &hull) {
987 (Some(depth), _) => HoleDepth {
988 depth,
989 point: args.drill_point,
990 },
991 (None, Some(hull)) => HoleDepth {
992 depth: reach_past(hull, axes, -1.0)?,
994 point: false,
995 },
996 (None, None) => {
997 return Err(GeopError::new(format!("solid {target:?} has no faces")));
998 }
999 };
1000 let named = namer.scoped(scope);
1001 let solid = hole_tool(model, &named, &named.name(&["tool"]), axes, &shape, depth)?;
1002 tools.push(Tool {
1003 solid,
1004 reach: ToolReach {
1005 up_to_next: (args.end == Extent::UpToNext)
1006 .then(|| (named.name(&["top", "q0"]), named.name(&["bottom", "q0"]))),
1007 scope: Some(scope.clone()),
1008 },
1009 });
1010 }
1011 let combine = Combine::cut(target);
1012 match combine_tools(model, &namer, step, &tools, &combine)? {
1013 Reach::Stopped(()) => Ok(()),
1014 Reach::RunsOn(e) => Err(e.with_context(
1015 "up to next: a hole does not come out of the solid all round — part of it runs on; drill it blind or through all",
1016 )),
1017 }
1018 }
1019}
1020
1021impl ModelOperation for Thread {
1022 fn build(model: &mut NamedModel, _: &Reader, step: &str, args: &ThreadArgs) -> GeopResult<()> {
1024 let namer = Namer::new("thread", step)?;
1025 let size = ThreadSize::named(&args.size)?;
1026 let (wall, placement) = args.placement(model)?;
1027 placement.check_fits(&size, args.face_name()?)?;
1028 let face = *(wall.faces.first()).ok_or_else(|| GeopError::new("the wall has no faces"))?;
1029 let why = "a modelled thread is cut into a solid";
1030 let target = model.solid_name_of_face(face, why)?;
1031 let tool = thread_tool(
1032 model,
1033 &namer,
1034 &namer.name(&["tool"]),
1035 &size,
1036 &placement,
1037 &wall,
1038 )?;
1039 let combine = Combine::cut(target);
1040 combine_tools(model, &namer, step, &[Tool::whole(tool)], &combine).and_then(Reach::stopped)
1041 }
1042}