Skip to main content

geop_ops_plastic/
lip.rs

1//! [`lip`] and [`groove`]: the joint between the two halves of an
2//! enclosure — a raised strip along one half's rim, and the recess in the
3//! other's that takes it.
4//!
5//! Both are a band along a chain of the rim face's edges (see
6//! [`crate::band`]), swept square off the rim: a lip `width` wide from the
7//! edges into the rim and `height` high, joined to the solid; a groove as
8//! wide as the lip and a `clearance` more, as deep as the lip is high and
9//! a `clearance` more, cut from it. The lip stands flush with the wall the
10//! edges are on, so on the edges of a shelled enclosure's rim towards its
11//! inside it carries the inner wall on up, and the groove on the mating
12//! half takes the inner part of its wall away. The cut reaches past the
13//! rim and past the wall into the air, `width` both ways, so that none of
14//! its faces lies on a face of the solid.
15//!
16//! A groove runs along any edges, a lip along straight and circular ones
17//! (see [`lip`]); corners between straight edges are mitred, other
18//! corners refused, and where the chain runs on smoothly is told by its
19//! walls. The rim has to be planar, and every edge of the chain convex:
20//! the wall falls away from the rim along it, so the space beside the
21//! edge, over the wall, is air.
22
23use geop_core_geometry::contains::surface::surface_could_contain;
24use geop_core_geometry::intersection::curve_curve_overlaps_and_crossings;
25use geop_core_geometry::nurb_curve::{NurbCurve2D, flatten_point};
26use geop_core_geometry::search::{MAX_NODES, min_subdivision_size};
27use geop_core_math::{
28    geop_error::{GeopError, GeopResult, WithContext},
29    primitives::CoordinateSystem,
30    scalars::Scalar,
31    vector::{Vector2, Vector3},
32    with_context,
33};
34use geop_core_topology::{
35    EdgeId, FaceId,
36    build::{BodySpec, CoedgeOn, CoedgeSpec},
37    contains::face::{PointClassification, face_contains},
38};
39use geop_lib_booleans::{Reach, combine_tools};
40use geop_lib_sweep::{SweepLoop, extrude};
41use geop_ops_model::{Combine, Tool};
42use geop_ops_model::{NamedModel, Namer, frame_along};
43
44use crate::{
45    band::{Chain, Piece},
46    common::halfway,
47};
48
49/// The chain of `face`'s edges `edges` — or, if none, its one hole — in
50/// the plane of the face: its frame, `w` the face's outward normal, and
51/// the chain in it, the face on its left. Fails for a face that is not
52/// planar, edges that are not one chain along one of its loops, edges that
53/// are neither straight nor circular, and edges the solid is not convex
54/// along.
55pub fn rim_chain(
56    part: &NamedModel,
57    face: FaceId,
58    edges: &[EdgeId],
59) -> GeopResult<(CoordinateSystem, Chain)> {
60    let model = part.topology();
61    let name = |id: geop_ops_model::RefId| part.name_of(id).unwrap_or("?").to_string();
62    let solid = part.solid_of_face(face, "a rim is a face of a solid")?;
63    let plane = part.plane_of_face(face, "a lip or groove runs along a flat rim")?;
64    let faces = model.solid_faces(solid)?;
65    let (spec, sources) = model.body_spec(&faces, true)?;
66    let f = sources
67        .faces
68        .iter()
69        .position(|&x| x == face)
70        .expect("a face of its solid");
71    let frame = frame_along(plane.point, &plane.normal)?;
72    let loops = spec.faces[f].loops();
73    let edge_index = |e: EdgeId| sources.edges.iter().position(|&x| x == e);
74
75    // The loop, and which of its coedges the chain runs along.
76    let (lp, picked): (&Vec<CoedgeSpec>, Vec<bool>) = if edges.is_empty() {
77        match spec.faces[f].holes.as_slice() {
78            [hole] => (hole, vec![true; hole.len()]),
79            holes => {
80                return Err(GeopError::new(format!(
81                    "face {} has {} holes: pick the edges to run along",
82                    name(face.into()),
83                    holes.len()
84                )));
85            }
86        }
87    } else {
88        let wanted: Vec<usize> = edges
89            .iter()
90            .map(|&e| {
91                edge_index(e).ok_or_else(|| {
92                    GeopError::new(format!(
93                        "edge {} is not an edge of face {}",
94                        name(e.into()),
95                        name(face.into())
96                    ))
97                })
98            })
99            .collect::<GeopResult<_>>()?;
100        let on = |c: &CoedgeSpec| matches!(c.on, CoedgeOn::Edge(e, _) if wanted.contains(&e));
101        let lp = loops.iter().find(|lp| lp.iter().any(on)).ok_or_else(|| {
102            GeopError::new(format!(
103                "edge {} does not bound face {}",
104                name(edges[0].into()),
105                name(face.into())
106            ))
107        })?;
108        let picked: Vec<bool> = lp.iter().map(on).collect();
109        if picked.iter().filter(|&&p| p).count() != wanted.len() {
110            return Err(GeopError::new(format!(
111                "the edges picked do not all run along one loop of face {}",
112                name(face.into())
113            )));
114        }
115        (lp, picked)
116    };
117    let n = lp.len();
118    let closed = picked.iter().all(|&p| p);
119    // An open chain starts after a coedge not picked; and is one run.
120    let first = if closed {
121        0
122    } else {
123        (0..n)
124            .find(|&k| picked[k] && !picked[(k + n - 1) % n])
125            .expect("some picked, some not")
126    };
127    let run: Vec<usize> = (0..n)
128        .map(|i| (first + i) % n)
129        .take_while(|&k| picked[k])
130        .collect();
131    if run.len() != picked.iter().filter(|&&p| p).count() {
132        return Err(GeopError::new(format!(
133            "the edges picked along face {} are not one chain: they leave gaps",
134            name(face.into())
135        )));
136    }
137
138    // The wall along coedge `k`: the face across its edge from the rim.
139    let uses = spec.edge_uses();
140    let wall = |k: usize| -> GeopResult<FaceId> {
141        let CoedgeOn::Edge(e, _) = lp[k].on else {
142            return Err(GeopError::new(format!(
143                "face {} has a loop that is a single point",
144                name(face.into())
145            )));
146        };
147        let g = uses[e]
148            .iter()
149            .map(|&(g, ..)| g)
150            .find(|&g| g != f)
151            .ok_or_else(|| GeopError::new("the edge bounds the rim alone"))?;
152        Ok(sources.faces[g])
153    };
154    // Whether the loop runs on smoothly from coedge `a` into coedge `b`:
155    // their walls tangent at the vertex between them. Asked of the faces,
156    // whose normals the surfaces state there (see
157    // `geop_ops_model::tangent`), not of the edges' tangents, which a
158    // traced edge only encloses.
159    let smooth = |a: usize, b: usize| -> GeopResult<bool> {
160        let (vertex, _) = spec.ends(lp[b].on)?;
161        let vertex = sources.vertices[vertex];
162        let p = model.normal_at_vertex(wall(a)?, vertex)?;
163        let q = model.normal_at_vertex(wall(b)?, vertex)?;
164        Ok(p.could_be_parallel(&q) && p.prod_dot(&q).definitely_greater(Scalar::ZERO))
165    };
166    let to_plane = |p: &Vector3| frame.to_uv(p);
167    let mut smooths = Vec::with_capacity(run.len());
168    let mut pieces = Vec::with_capacity(run.len());
169    let mut joints = Vec::with_capacity(run.len() + 1);
170    for &k in &run {
171        let CoedgeOn::Edge(e, sense) = lp[k].on else {
172            return Err(GeopError::new(format!(
173                "face {} has a loop that is a single point",
174                name(face.into())
175            )));
176        };
177        let edge_name = name(sources.edges[e].into());
178        let curve = sense.orient(&spec.edges[e].curve);
179        let center = curve.as_arc()?.map(|arc| to_plane(&arc.circle.center));
180        convex_along(&spec, e, f, &curve, &plane.normal)
181            .map_err(|err| err.with_context(format!("edge {edge_name} of the rim")))?;
182        // Into the plane's `(u, v)`, homogeneous: an affine map.
183        let flat = curve
184            .control_points
185            .iter()
186            .map(|cp| flatten_point(cp, frame.origin(), frame.u(), frame.v()))
187            .collect();
188        let mut curve2 = NurbCurve2D::try_new(curve.degree, flat, curve.knot_vector.clone())?;
189        if curve.as_line()?.is_some() {
190            // A line, however it was given: the band offsets and mitres it
191            // as one.
192            curve2 = NurbCurve2D::line(curve2.start()?, curve2.end()?)?;
193        }
194        let (start, _) = spec.ends(lp[k].on)?;
195        joints.push(name(sources.vertices[start].into()));
196        smooths.push((closed || k != first) && smooth((k + n - 1) % n, k)?);
197        pieces.push(Piece {
198            curve: curve2,
199            center,
200            name: edge_name,
201        });
202    }
203    if !closed {
204        let (_, end) = spec.ends(lp[*run.last().unwrap()].on)?;
205        joints.push(name(sources.vertices[end].into()));
206        // An open chain ends square to its last edge. At a corner of the
207        // loop that end lies in the plane of the next edge's wall, along
208        // that edge: a face on an edge that already exists. Only where the
209        // loop runs on smoothly does it cross the rim cleanly.
210        let last = *run.last().unwrap();
211        for (before, after, joint) in [
212            ((first + n - 1) % n, first, &joints[0]),
213            (last, (last + 1) % n, joints.last().unwrap()),
214        ] {
215            if !smooth(before, after)? {
216                return Err(GeopError::new(format!(
217                    "the edges picked end at vertex {joint}, a corner of face {}: a lip or groove along part of a loop has to end where the loop runs on smoothly — pick the whole loop, or end the chain at a round",
218                    name(face.into())
219                )));
220            }
221        }
222    }
223    // The pieces have to meet exactly: each starts where the one before
224    // ended, as the vertex between them.
225    let mut chain = Chain {
226        pieces,
227        joints,
228        smooth: smooths,
229        closed,
230    };
231    stitch(&mut chain)?;
232    Ok((frame, chain))
233}
234
235/// Makes each piece of `chain` start exactly where the one before ends:
236/// both ends were mapped from the same vertex, each by its own control
237/// point; the two enclosures of the one point are intersected.
238fn stitch(chain: &mut Chain) -> GeopResult<()> {
239    let n = chain.pieces.len();
240    let count = if chain.closed { n } else { n - 1 };
241    for i in 0..count {
242        let j = (i + 1) % n;
243        let end = *chain.pieces[i].curve.control_points.last().unwrap();
244        let start = chain.pieces[j].curve.control_points[0];
245        let (a, b) = (end.dehomogenized()?, start.dehomogenized()?);
246        if !a.could_be_equal(&b) {
247            return Err(GeopError::new(format!(
248                "{} does not end where {} starts",
249                chain.pieces[i].name, chain.pieces[j].name
250            )));
251        }
252        let at = Vector2::from_array([a[0].intersect(b[0]), a[1].intersect(b[1])]);
253        let set = |h: &mut Vector3| *h = at.homogeneous(h[2]);
254        set(chain.pieces[i].curve.control_points.last_mut().unwrap());
255        set(&mut chain.pieces[j].curve.control_points[0]);
256    }
257    Ok(())
258}
259
260/// Fails unless the solid is convex along edge `e` of the rim face `f`
261/// (outward normal `up`), which `curve` runs along the way `f`'s loop
262/// does: the other face's outward normal, halfway along, points away from
263/// the rim — to the right of the edge, the rim lying on its left.
264fn convex_along(
265    spec: &BodySpec,
266    e: usize,
267    f: usize,
268    curve: &geop_core_topology::Curve3,
269    up: &Vector3,
270) -> GeopResult<()> {
271    let other = spec.edge_uses()[e]
272        .iter()
273        .map(|&(g, ..)| g)
274        .find(|&g| g != f)
275        .ok_or_else(|| GeopError::new("the edge bounds the rim alone"))?;
276    let (point, along) = halfway(curve)?;
277    let right = along.prod_cross(up);
278    let normal = spec.faces[other]
279        .surface
280        .normal_at(&point, MAX_NODES, min_subdivision_size())?;
281    if !normal.prod_dot(&right).definitely_greater(Scalar::ZERO) {
282        return Err(GeopError::new(
283            "the solid is not convex along it: a lip or groove runs along an edge where the wall falls away from the rim",
284        ));
285    }
286    Ok(())
287}
288
289/// Fails unless the band along `chain` reaches no further than `far` into
290/// the rim face `face`, the plane `frame` holds it in, without meeting
291/// another of its edges: a lip or groove `what` that wide has to stand on
292/// the rim. Its far side crossing none of the rim's edges lies inside the
293/// rim or out of it all along, and one point of it says which.
294fn fits_on_rim(
295    part: &NamedModel,
296    face: FaceId,
297    frame: &CoordinateSystem,
298    chain: &Chain,
299    far: Scalar,
300    what: &str,
301) -> GeopResult<()> {
302    let model = part.topology();
303    let name = |id: geop_ops_model::RefId| part.name_of(id).unwrap_or("?").to_string();
304    let refuse = |why: String| {
305        GeopError::new(format!(
306            "a {what} {:?} wide does not fit on the rim, face {}: {why} — make it narrower than the rim",
307            far.to_f64(),
308            name(face.into())
309        ))
310    };
311    let far_side = chain.offset(far)?;
312    for coedge in model.iterate_face_coedges(face) {
313        let edge = model.get_coedge(coedge)?.edge()?;
314        let curve = &model.get_edge(edge)?.curve;
315        let flat = curve
316            .control_points
317            .iter()
318            .map(|cp| flatten_point(cp, frame.origin(), frame.u(), frame.v()))
319            .collect();
320        let flat = NurbCurve2D::try_new(curve.degree, flat, curve.knot_vector.clone())?;
321        for side in &far_side {
322            let (overlaps, crossings) = curve_curve_overlaps_and_crossings(
323                side,
324                &flat,
325                Scalar::ZERO,
326                MAX_NODES,
327                min_subdivision_size(),
328            )?;
329            if !(overlaps.is_empty() && crossings.is_empty()) {
330                return Err(refuse(format!(
331                    "its far side meets the rim's edge {}",
332                    name(edge.into())
333                )));
334            }
335        }
336    }
337    let side = &far_side[0];
338    let at = side.evaluate(side.mid_parameter()?)?;
339    let point = frame
340        .origin()
341        .add(&frame.u().prod_scalar(at[0]))
342        .add(&frame.v().prod_scalar(at[1]));
343    let surface = &model.get_face(face)?.surface;
344    let inside = match surface_could_contain(surface, &point, MAX_NODES, min_subdivision_size())? {
345        Some((u, v)) => face_contains(
346            model,
347            face,
348            u,
349            v,
350            MAX_NODES,
351            min_subdivision_size(),
352            RIM_SEED,
353        )?,
354        None => PointClassification::Outside,
355    };
356    if inside != PointClassification::Inside {
357        return Err(refuse("its far side lies past the rim's other edge".into()));
358    }
359    Ok(())
360}
361
362/// Seeds the ray directions [`fits_on_rim`] asks the rim with.
363const RIM_SEED: u64 = 0x5249_4D00_0000_0001;
364
365/// The sizes of a lip, or of the lip a groove takes.
366#[derive(Clone, Copy, Debug)]
367pub struct LipSize {
368    pub width: Scalar,
369    pub height: Scalar,
370}
371
372fn check_size(what: &str, value: Scalar) -> GeopResult<()> {
373    if !value.definitely_greater(Scalar::ZERO) {
374        return Err(GeopError::new(format!(
375            "a lip's {what} is more than zero, not {:?}",
376            value.to_f64()
377        )));
378    }
379    Ok(())
380}
381
382/// Raises a lip along the edges `edges` of the rim face `face` (see the
383/// module docs), joined to its solid, for the operation `operation_id`
384/// whose names `namer` builds: the result is named `namer`'s root, the
385/// lip's faces along an edge `E` `N(E)` (on the wall) and `N(E,far)`
386/// (inside the rim), its top `N(end)`.
387pub fn lip(
388    part: &mut NamedModel,
389    namer: &Namer,
390    operation_id: &str,
391    face: FaceId,
392    edges: &[EdgeId],
393    size: LipSize,
394) -> GeopResult<()> {
395    let ctx = with_context!(
396        "lip({}, face={face}, edges={edges:?}, {size:?})",
397        namer.root()
398    );
399    check_size("width", size.width).with_context(ctx)?;
400    check_size("height", size.height).with_context(ctx)?;
401    let target = part
402        .solid_name_of_face(face, "a rim is a face of a solid")
403        .with_context(ctx)?;
404    let (frame, chain) = rim_chain(part, face, edges).with_context(ctx)?;
405    // The lip's face along an edge stands flush with the wall, the edge
406    // swept up. Along a line or an arc that is the wall's own surface
407    // carried on; along any other curve, a traced edge only enclosing
408    // where the wall meets the rim, it is a surface a hair off the wall's,
409    // which the union cannot join to it.
410    if let Some(piece) = chain
411        .pieces
412        .iter()
413        .find(|p| !p.is_straight() && p.center.is_none())
414    {
415        return Err(GeopError::new(format!(
416            "edge {} is neither straight nor circular: a lip stands flush with the wall along it, and its face there would only approximate the wall's surface — a lip runs along straight and circular edges, a groove along any",
417            piece.name
418        )))
419        .with_context(ctx);
420    }
421    fits_on_rim(part, face, &frame, &chain, size.width, "lip").with_context(ctx)?;
422    let loops: Vec<SweepLoop> = chain
423        .band(Scalar::ZERO, size.width)
424        .with_context(ctx)?
425        .into_iter()
426        .map(SweepLoop::plain)
427        .collect();
428    let built = extrude(
429        part,
430        namer,
431        Some(&namer.name(&["tool"])),
432        &frame,
433        &[Scalar::ZERO, size.height],
434        &loops,
435    )
436    .with_context(ctx)?;
437    let tool = Tool::whole(built.solid.expect("extruded as a solid"));
438    let combine = Combine::join(target);
439    combine_tools(part, namer, operation_id, &[tool], &combine)
440        .and_then(Reach::stopped)
441        .with_context(ctx)
442}
443
444/// Cuts the groove that takes a lip of `size` along the edges `edges` of
445/// the rim face `face` (see the module docs), `clearance` wider and deeper,
446/// from its solid; named like a [`lip`].
447pub fn groove(
448    part: &mut NamedModel,
449    namer: &Namer,
450    operation_id: &str,
451    face: FaceId,
452    edges: &[EdgeId],
453    size: LipSize,
454    clearance: Scalar,
455) -> GeopResult<()> {
456    let ctx = with_context!(
457        "groove({}, face={face}, edges={edges:?}, {size:?}, clearance={clearance:?})",
458        namer.root()
459    );
460    check_size("width", size.width).with_context(ctx)?;
461    check_size("height", size.height).with_context(ctx)?;
462    if clearance.definitely_less(Scalar::ZERO) {
463        return Err(GeopError::new(format!(
464            "a groove's clearance is not less than zero, not {:?}",
465            clearance.to_f64()
466        )))
467        .with_context(ctx);
468    }
469    let target = part
470        .solid_name_of_face(face, "a rim is a face of a solid")
471        .with_context(ctx)?;
472    let (frame, chain) = rim_chain(part, face, edges).with_context(ctx)?;
473    fits_on_rim(
474        part,
475        face,
476        &frame,
477        &chain,
478        size.width.add(clearance),
479        "groove",
480    )
481    .with_context(ctx)?;
482    // Past the wall into the air, and above the rim, by the lip's width: a
483    // free choice, only so no face of the cut lies on one of the solid.
484    let air = size.width;
485    let loops: Vec<SweepLoop> = chain
486        .band(air.neg(), size.width.add(clearance))
487        .with_context(ctx)?
488        .into_iter()
489        .map(SweepLoop::plain)
490        .collect();
491    let built = extrude(
492        part,
493        namer,
494        Some(&namer.name(&["tool"])),
495        &frame,
496        &[air, size.height.add(clearance).neg()],
497        &loops,
498    )
499    .with_context(ctx)?;
500    let tool = Tool::whole(built.solid.expect("extruded as a solid"));
501    let combine = Combine::cut(target);
502    combine_tools(part, namer, operation_id, &[tool], &combine)
503        .and_then(Reach::stopped)
504        .with_context(ctx)
505}