Skip to main content

geop_ops_hole/
operation.rs

1//! [`Hole`]: drill holes into a planar face at points on it, the way a hole
2//! wizard does — simple, counterbored, countersunk or tapped, sized by ISO
3//! tables or by hand, or tapped for an NPT pipe thread. [`Thread`]: put a
4//! thread on a round face, straight or tapered, recorded or modelled.
5
6use 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
47/// Where a hole goes: a vertex, a datum point or a frame's origin, a
48/// sketch's point — or a sketch, standing for its points that no line,
49/// arc or spline runs through (see [`PointsRef`]).
50pub type HoleCentre = PointsRef;
51
52/// A flat face of the part, by name.
53pub type PlaneRef = ElementReference<NamedModel, Plane>;
54
55/// A face of the part, by name.
56pub type FaceRef = ElementReference<NamedModel, Face>;
57
58/// What a hole is for.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum HoleKind {
62    /// A plain drilled hole: a screw's clearance hole, or any diameter.
63    #[default]
64    Simple,
65    /// Sunk for a socket head cap screw's head.
66    Counterbore,
67    /// Sunk 90° for a countersunk screw's head.
68    Countersink,
69    /// Drilled to the tap drill and threaded: a cosmetic thread recorded on
70    /// its wall. Sized by a pipe thread, reamed to its taper.
71    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/// How a hole is sized: by an ISO metric screw — `size` `M6`, and for a
84/// clearance hole how loose a `fit` — by an NPT pipe thread, `size` `1/2`,
85/// for a tapped hole, or by hand.
86#[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    /// The drill's `diameter`; for a counterbore or countersink the head's
98    /// diameter on the face, and for a counterbore its depth.
99    Custom {
100        diameter: f64,
101        #[serde(default)]
102        head_diameter: f64,
103        #[serde(default)]
104        head_depth: f64,
105    },
106}
107
108/// Drills holes into the planar face `face` at each of `points`, for the
109/// operation `H`: every hole a solid of revolution around the face's
110/// normal through its point, cut out of the face's solid in one boolean.
111/// The result replaces the solid and is named `hole(H)`.
112///
113/// A point is a sketch's point, a datum point or a vertex, lying on the
114/// face; a sketch stands for its points that no line, arc or spline runs
115/// through — points placed on their own, and circles' centres.
116///
117/// What each hole leaves is named after its point `X` — `K,p3` for the
118/// point `p3` of the sketch `K`, else the point's name: its wall
119/// `hole(H,X,wall,q0)` .. `q3` by quarter turn, a counterbore's
120/// `hole(H,X,counterbore,q)` and `hole(H,X,shoulder,q)`, a countersink's
121/// `hole(H,X,countersink,q)`, a blind hole's bottom `hole(H,X,bottom,q)` or
122/// drill point `hole(H,X,point,q)` (see `crate::hole`); what the boolean
123/// creates is named `combine(H,X,...)`. A tapped hole's cosmetic thread is
124/// the part's thread `hole(H,X,thread)` (see [`threads()`]).
125#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
126pub struct Hole;
127
128#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
129pub struct HoleArgs {
130    /// The planar face to drill into; none, for a step that has not picked
131    /// it.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub face: Option<PlaneRef>,
134    /// Where the holes go.
135    pub points: Vec<HoleCentre>,
136    pub kind: HoleKind,
137    pub standard: Standard,
138    /// How deep: blind, measured from the face to the end of the drill's
139    /// full diameter; up to where it comes out of the solid again; or
140    /// through all of it.
141    pub end: Extent,
142    /// A blind hole ends in a drill's 118° point rather than flat.
143    #[serde(default)]
144    pub drill_point: bool,
145    /// How far a tapped hole is threaded from the face: all the way, if
146    /// none.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub thread_length: Option<f64>,
149}
150
151impl HoleArgs {
152    /// The shape across the holes.
153    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                // Reamed to the taper, the thread's minor diameter at the
204                // face and below it.
205                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    /// The dimensions the holes are made with, as the dialog says them.
244    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
271/// The circle of `diameter` around `centre` in the plane normal to
272/// `normal`, as a polyline: what a hole looks like on its face.
273fn 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
288/// The choices of the ISO sizes.
289fn size_choices() -> Vec<Choice> {
290    iso::SIZES
291        .iter()
292        .map(|s| Choice::new(s.name, s.name))
293        .collect()
294}
295
296/// The choices of the NPT sizes.
297fn npt_choices() -> Vec<Choice> {
298    (pipe::SIZES.iter())
299        .map(|s| Choice::new(s.name, format!("{}\"", s.name)))
300        .collect()
301}
302
303/// The choices of every thread size, ISO and NPT, by the name
304/// [`ThreadSize::named`] reads.
305fn thread_choices() -> Vec<Choice> {
306    (ThreadSize::all())
307        .map(|s| Choice::new(s.name(), s.name()))
308        .collect()
309}
310
311/// Where the holes go: the solid they are drilled into, by name, and each
312/// hole's scope and frame — at its centre on the face, `z` the face's
313/// normal, out of the solid — as `reader` reads them.
314struct Sites {
315    target: String,
316    holes: Vec<(String, CoordinateSystem)>,
317}
318
319/// The [`Sites`] of the holes `args` drills, in the part `reader` reads.
320fn 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/// Where a hole step drills each of its holes, as the part before it put
345/// them, how deep a blind one goes and how far a tapped one is threaded:
346/// what their holes and threads are recorded from, on the walls the holes
347/// left.
348#[derive(Clone, Debug, PartialEq)]
349struct Drilled {
350    holes: Vec<(String, CoordinateSystem)>,
351    /// How deep, for a blind hole; none for one through the solid.
352    blind: Option<f64>,
353    threaded: Option<f64>,
354}
355
356/// Each hole step's own, made before its model.
357impl Target for Drilled {}
358
359/// Read whole.
360impl 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
372/// Where the hole step `step` drills its holes.
373fn drilled(step: &str) -> TargetReference<Drilled> {
374    TargetReference::new(format!("drilled:{step}"))
375}
376
377/// Where the hole `args` drills its holes, as the part before it is.
378fn 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
387/// Records every hole the hole step `step` drilled, in the model it left:
388/// its axis, the screw it is for, where a head seats in it and how deep it
389/// runs — as far as its wall goes along its axis (see [`HoleSite`]).
390fn 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        // How far its wall goes from the face, along its axis.
418        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
456/// Records the cosmetic thread of every hole the tapped hole `step`
457/// drilled, on its wall in the model the hole left.
458fn 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    /// No face or point yet; a simple M6 clearance hole, 10 deep.
495    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    /// Drills the holes, then records them — and, tapped, their threads —
511    /// where the part before the step put them and on the walls the holes
512    /// left.
513    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    /// The face and the points, picked; the kind of hole; its standard,
534    /// size and fit — or its diameters by hand — and what they come to; how
535    /// deep. Each hole drawn as a circle of its diameter on the face.
536    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                        // Starting from what the ISO size made.
582                        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                    // A pipe thread is tapped.
600                    ("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        // Each hole, drawn on the face.
733        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
757/// The cosmetic thread of the tapped hole `(X, axes)` drilled at `axes`
758/// as `shape` says — on its wall in `solid` of `model` — the thread of
759/// `size` for the hole's scope `X`: from the face down, `length` far — a
760/// blind hole as deep as it was drilled, unless told otherwise — or, if
761/// none, all the way. The wall is where the hole's own axis, diameter and
762/// taper put it.
763fn 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    // Measured into the solid from the face.
782    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/// Puts an ISO metric or an NPT pipe thread on the round face `face` — a
789/// shaft's or a hole's, the whole wall it is part of, straight for a metric
790/// thread and tapered 1:16 for a pipe thread — for the operation `T`. From
791/// one
792/// end of the wall, `length` far or all of it: from its open end — where a
793/// shaft ends, where a hole was drilled from — or, if both or neither are
794/// open, from one of them; from the other if `reversed`.
795///
796/// Recorded, it is the part's cosmetic thread `thread(T)` (see
797/// [`threads()`]), which drawings and the viewer read; the solid is left as it is. Modelled, its profile is
798/// swept along its helix and cut out of the solid (see `crate::thread`),
799/// which is replaced by `thread(T)`; the cut faces are named
800/// `thread(T,c0,q)` .. `thread(T,c3,q)` for each quarter turn `q`, and what
801/// the boolean creates `combine(T,...)`.
802#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
803pub struct Thread;
804
805#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
806pub struct ThreadArgs {
807    /// A round face of the wall to thread; none, for a step that has not
808    /// picked it.
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub face: Option<FaceRef>,
811    /// The ISO metric size, `M6`, or the NPT size, `1/2 NPT`.
812    pub size: String,
813    /// How far it runs: the whole wall, if none.
814    #[serde(default, skip_serializing_if = "Option::is_none")]
815    pub length: Option<f64>,
816    /// Start from the other end of the wall.
817    #[serde(default)]
818    pub reversed: bool,
819    /// Cut the thread's profile into the solid, rather than record it.
820    #[serde(default)]
821    pub modelled: bool,
822}
823
824impl ThreadArgs {
825    /// Where the thread runs in `model`, on the wall of its face.
826    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    /// The name of the face it threads. Refused if none is picked.
834    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
841/// Records the cosmetic thread `thread(T)` of the step `T`, on the wall of
842/// its face in the model as it is.
843fn 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    /// No face yet; M6, recorded, the whole wall.
871    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    /// Modelled, cut into the solid; recorded, one more of the part's
882    /// threads.
883    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    /// The face, picked; the size; how far; which end; whether modelled.
894    /// The thread drawn as its helix.
895    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                    // Into the solid: against the face's normal.
993                    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    /// Cuts the thread's profile into the solid: a modelled thread.
1023    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}