Skip to main content

geop_lib_booleans/
imprint.rs

1//! Imprinting one body onto another: dividing the faces of each along the
2//! curves where the other's boundary crosses them, and nowhere else.
3//!
4//! [`imprint`] is [`remesh`] alone, with no faces classified and none
5//! dropped: both bodies are kept, each divided, and each given its own copy
6//! of the curves the remesh made them share.
7//!
8//! [`imprint_on_face`] divides only one face. [`remesh`] imprints whole
9//! bodies onto each other, so a tool remeshed with the face's body would be
10//! imprinted on every face of it the tool crosses. The crossings with the
11//! one face are therefore found on a copy of that face standing on its own:
12//! remeshed with the copy, each tool carries an edge along every curve where
13//! it crosses the face, shared with the copy. The copy then goes, and those
14//! edges are imprinted onto the face itself the way a remesh imprints an
15//! edge lying within a face: the face's boundary edges are split where the
16//! curves end on them, and each curve is spliced into the piece of the face
17//! it lies in.
18
19use std::collections::{BTreeMap, HashMap, HashSet};
20
21use geop_core_math::{
22    geop_error::{GeopError, GeopResult, WithContext},
23    vector::Vector2,
24    with_context,
25};
26use geop_core_topology::{
27    Body, EdgeId, FaceId, Model, VertexId,
28    contains::face::{PointClassification, face_contains},
29};
30use geop_ops_model::{NamedModel, Namer, origin_of};
31
32use crate::{
33    naming::BooleanNaming,
34    remesh::{
35        RemeshParams, remesh, remesh_vertices::remesh_vertices,
36        remesh_vertices_x_edges::remesh_vertices_x_edges,
37    },
38};
39
40/// Fixed PRNG seed for `face_contains`' ray casting, whose answer does not
41/// depend on it: a constant keeps the imprint reproducible.
42const FACE_CONTAINS_SEED: u64 = 0x5DEE_CE66_D1CE_4E5B;
43
44/// Every edge the faces of `body` run along.
45fn body_edges(model: &Model, body: Body) -> GeopResult<HashSet<EdgeId>> {
46    let mut edges = HashSet::new();
47    for face in model.body_faces(body)? {
48        edges.extend(model.face_edges(face));
49    }
50    Ok(edges)
51}
52
53/// The edges faces of both `a` and `b` run along — after a [`remesh`] of the
54/// two, the curves where their boundaries cross — in the order of their
55/// names, which does not depend on how they were found.
56pub(crate) fn shared_edges(model: &NamedModel, a: Body, b: Body) -> GeopResult<Vec<EdgeId>> {
57    let topology = model.topology();
58    let on_b = body_edges(topology, b)?;
59    let mut shared: Vec<(String, EdgeId)> = body_edges(topology, a)?
60        .into_iter()
61        .filter(|e| on_b.contains(e))
62        .map(|e| Ok((model.name(e)?.to_string(), e)))
63        .collect::<GeopResult<_>>()?;
64    shared.sort();
65    Ok(shared.into_iter().map(|(_, e)| e).collect())
66}
67
68/// The face of the tools the curve `edge` runs along, as an argument of a
69/// name of `namer`: `X` for a face named `N(X)`. The least of them, if it
70/// runs along two, where the tool's faces meet.
71fn tool_face_of(
72    model: &NamedModel,
73    namer: &Namer,
74    edge: EdgeId,
75    tool_faces: &HashSet<FaceId>,
76) -> GeopResult<String> {
77    let topology = model.topology();
78    let mut names = Vec::new();
79    for coedge in topology.coedges_of_edge(edge) {
80        let on = topology.get_coedge(coedge)?.face;
81        if tool_faces.contains(&on) {
82            let name = model.name(on)?;
83            names.push(namer.arguments_of(name).unwrap_or(name).to_string());
84        }
85    }
86    (names.into_iter().min())
87        .ok_or_else(|| GeopError::new(format!("the curve {edge} runs along no face of the tools")))
88}
89
90/// Imprints the bodies `a` and `b` onto each other: every face of each is
91/// divided along the curves where the other's boundary crosses it, and
92/// both are kept, sharing nothing. Returns `a`'s edges along those curves.
93///
94/// What the imprint creates is named as a boolean names it (see `naming`),
95/// and `a`'s edges and vertices along the curves keep those names. `b`'s
96/// copies of them are named `N(X)` for the name `X` they share.
97///
98/// Fails, leaving both bodies as they were, if the bodies' boundaries do
99/// not meet: there is nothing to imprint.
100pub fn imprint(
101    model: &mut NamedModel,
102    namer: &Namer,
103    a: Body,
104    b: Body,
105    params: RemeshParams,
106) -> GeopResult<Vec<EdgeId>> {
107    let ctx = with_context!("imprint(name={}, a={a}, b={b})", namer.root());
108    let before = model.clone();
109    remesh(model, namer, a, b, params).with_context(&ctx)?;
110    let curves = shared_edges(model, a, b).with_context(&ctx)?;
111    if curves.is_empty() {
112        *model = before;
113        return Err(ctx(GeopError::new(
114            "imprint: the bodies' boundaries do not meet",
115        )));
116    }
117    // `b` again, with its own copy of what it shares with `a`.
118    let (spec, names) = model.body_record(b).with_context(&ctx)?;
119    model.delete_bodies(&[b]).with_context(&ctx)?;
120    let names = names.renamed(|name| {
121        if model.names().is_taken(name) {
122            namer.name(&[name])
123        } else {
124            name.to_string()
125        }
126    });
127    model.build_body(spec, names).with_context(&ctx)?;
128    Ok(curves)
129}
130
131/// Divides `face` along the curves where the boundaries of the bodies
132/// `tools` cross it, and returns the edges imprinted there. The tools are
133/// consumed; nothing else
134/// of the face's body changes but the boundary edges of the face being
135/// split where a curve ends on them.
136///
137/// Named by `namer` as a boolean names what it creates (see `naming`), but
138/// for the curves and the face's pieces: a curve along the tool's face
139/// `N(X)` takes that name, the tool's face being gone, and is `N(X,k)` for
140/// its `k`-th stretch if the face crosses this one several times. A piece
141/// split off the face `F` along it is `N(F,X)` or `N(F,X,k)`, and the other
142/// piece keeps the name `F`. So what the user reads of a curve is what made
143/// it, not where it happened to end.
144///
145/// Fails, leaving the part with the face's copy and the tools in it, if no
146/// tool crosses the face, a curve runs along one of its edges, a curve ends
147/// inside it, or a curve only joins two of its boundaries: each curve has to
148/// have a piece of the face on either side.
149pub fn imprint_on_face(
150    model: &mut NamedModel,
151    namer: &Namer,
152    face: FaceId,
153    tools: &[Body],
154    params: RemeshParams,
155) -> GeopResult<Vec<EdgeId>> {
156    let ctx = with_context!(
157        "imprint_on_face(name={}, face={face}, tools={tools:?})",
158        namer.root()
159    );
160    let body = model.topology().body_of_face(face).with_context(&ctx)?;
161
162    // The face on its own, crossed by every tool.
163    let copy = model
164        .copy_faces(&[face], None, |name| namer.name(&["copy", name]))
165        .and_then(|built| built.body())
166        .with_context(&ctx)?;
167    for &tool in tools {
168        remesh(model, namer, copy, tool, params).with_context(&ctx)?;
169    }
170
171    // The curves: the edges the copy now shares with a tool, each with
172    // where on the face it runs — its pcurve's middle, on the copy, whose
173    // surface is the face's. In the order of their names, which does not
174    // depend on how they were found.
175    let topology = model.topology();
176    let on_copy = body_edges(topology, copy).with_context(&ctx)?;
177    let mut on_tools = HashSet::new();
178    for &tool in tools {
179        on_tools.extend(body_edges(topology, tool).with_context(&ctx)?);
180    }
181    let mut curves: Vec<(String, EdgeId, Vector2)> = Vec::new();
182    for &edge in on_copy.intersection(&on_tools) {
183        let coedge = topology
184            .coedges_of_edge(edge)
185            .into_iter()
186            .find(|&c| {
187                topology
188                    .get_coedge(c)
189                    .is_ok_and(|c| topology.body_of_face(c.face).is_ok_and(|b| b == copy))
190            })
191            .ok_or_else(|| {
192                ctx(GeopError::new(format!(
193                    "imprint: edge {edge} is on no face of the copy"
194                )))
195            })?;
196        let pcurve = &topology.get_coedge(coedge)?.pcurve;
197        let middle = pcurve.evaluate(pcurve.mid_parameter()?)?;
198        curves.push((model.name(edge).map(str::to_string)?, edge, middle));
199    }
200    curves.sort_by(|a, b| a.0.cmp(&b.0));
201    let face_name = model.name(face).map(str::to_string)?;
202    let tool_faces: HashSet<FaceId> = (tools.iter())
203        .map(|&tool| topology.body_faces(tool))
204        .collect::<GeopResult<Vec<_>>>()
205        .with_context(&ctx)?
206        .into_iter()
207        .flatten()
208        .collect();
209    if curves.is_empty() {
210        return Err(ctx(GeopError::new(format!(
211            "nothing crosses the face {face_name:?}: what divides it has to run across it"
212        ))));
213    }
214
215    // An edge of the copy has a face of the copy on both sides, unless it
216    // is on the copy's boundary: a curve there runs along an edge of the
217    // face, and has nothing to divide.
218    let on_boundary = |edge: EdgeId| {
219        let of_copy = (topology.coedges_of_edge(edge).into_iter()).filter(|&c| {
220            (topology.get_coedge(c))
221                .is_ok_and(|c| topology.body_of_face(c.face).is_ok_and(|b| b == copy))
222        });
223        of_copy.count() < 2
224    };
225    let mut along: Vec<&str> = (curves.iter())
226        .filter(|(_, edge, _)| on_boundary(*edge))
227        .map(|(name, _, _)| {
228            // The face's own edge: what this one is a copy, or a piece of a
229            // copy, of.
230            let copy = origin_of(name).unwrap_or(name);
231            let arguments = namer.arguments_of(copy).unwrap_or(copy);
232            arguments.strip_prefix("copy,").unwrap_or(arguments)
233        })
234        .collect();
235    if !along.is_empty() {
236        along.sort();
237        along.dedup();
238        return Err(ctx(GeopError::new(format!(
239            "what divides the face {face_name:?} runs along its edge {along:?}, which already bounds it: draw it clear of the edge"
240        ))));
241    }
242
243    // A curve ending inside the face divides nothing: each of its ends has
244    // to be on the face's boundary — an edge of the copy no tool shares —
245    // or where another curve goes on.
246    let boundary: HashSet<VertexId> = on_copy
247        .difference(&on_tools)
248        .map(|&e| topology.get_edge(e).map(|e| [e.start_vertex, e.end_vertex]))
249        .collect::<GeopResult<Vec<_>>>()?
250        .into_iter()
251        .flatten()
252        .collect();
253    let mut ends: HashMap<VertexId, usize> = HashMap::new();
254    for (_, edge, _) in &curves {
255        let edge = topology.get_edge(*edge)?;
256        *ends.entry(edge.start_vertex).or_default() += 1;
257        *ends.entry(edge.end_vertex).or_default() += 1;
258    }
259    let loose = |v: VertexId| ends[&v] == 1 && !boundary.contains(&v);
260    let mut dangling = Vec::new();
261    for (_, edge, _) in &curves {
262        let at = topology.get_edge(*edge)?;
263        if loose(at.start_vertex) || loose(at.end_vertex) {
264            dangling.push(tool_face_of(model, namer, *edge, &tool_faces)?);
265        }
266    }
267    if !dangling.is_empty() {
268        dangling.sort();
269        dangling.dedup();
270        return Err(ctx(GeopError::new(format!(
271            "{dangling:?} ends inside the face {face_name:?}, and a dangling edge divides nothing: to divide it, a curve has to cross it or close on itself"
272        ))));
273    }
274
275    model.delete_bodies(&[copy]).with_context(&ctx)?;
276
277    // The curves onto the face itself: its boundary split where they end on
278    // it, each spliced into the piece it lies in.
279    let mut bodies = vec![body];
280    bodies.extend(tools);
281    let mut naming = BooleanNaming::new(model, namer, &bodies).with_context(&ctx)?;
282    for &tool in tools {
283        remesh_vertices(model, body, tool).with_context(&ctx)?;
284        remesh_vertices_x_edges(model, &mut naming, body, tool, &params).with_context(&ctx)?;
285    }
286    let mut pieces = vec![face];
287    let mut split_along: Vec<(FaceId, EdgeId)> = Vec::new();
288    for (curve_name, edge, middle) in &curves {
289        let mut target = None;
290        for &piece in &pieces {
291            let class = face_contains(
292                model.topology(),
293                piece,
294                middle[0],
295                middle[1],
296                params.max_nodes,
297                params.min_subdivision_size,
298                FACE_CONTAINS_SEED,
299            )
300            .with_context(&ctx)?;
301            if class == PointClassification::Inside {
302                target = Some(piece);
303                break;
304            }
305        }
306        let target = target.ok_or_else(|| {
307            ctx(GeopError::new(format!(
308                "imprint: the curve {curve_name:?}, at {middle:?} on the face, lies inside none of its pieces"
309            )))
310        })?;
311        let split = model
312            .splice_edge_into_face(
313                *edge,
314                target,
315                params.max_nodes,
316                params.min_subdivision_size,
317                naming.provisional(),
318            )
319            .with_context(&ctx)?;
320        naming.face_split(target, *edge, split)?;
321        pieces.extend(split);
322        split_along.extend(split.map(|piece| (piece, *edge)));
323    }
324    naming.finish(model).with_context(&ctx)?;
325    // A curve with one piece of the face on both of its sides joined two of
326    // the face's boundaries, and divides nothing.
327    let topology = model.topology();
328    let mut joining = Vec::new();
329    for (_, edge, _) in &curves {
330        let mut sides = HashSet::new();
331        for coedge in topology.coedges_of_edge(*edge) {
332            let side = topology.get_coedge(coedge)?.face;
333            if pieces.contains(&side) && !sides.insert(side) {
334                joining.push(tool_face_of(model, namer, *edge, &tool_faces)?);
335                break;
336            }
337        }
338    }
339    if !joining.is_empty() {
340        joining.sort();
341        joining.dedup();
342        return Err(ctx(GeopError::new(format!(
343            "{joining:?} only joins two of the boundaries of the face {face_name:?}, as a line from its outside to a hole does, and a piece of it on both sides divides nothing: to divide it, a curve has to have another piece on its other side"
344        ))));
345    }
346
347    // The curves after what made them, the pieces after the curves.
348    let mut stretches: BTreeMap<String, Vec<EdgeId>> = BTreeMap::new();
349    for (_, edge, _) in &curves {
350        let made_by = tool_face_of(model, namer, *edge, &tool_faces).with_context(&ctx)?;
351        stretches.entry(made_by).or_default().push(*edge);
352    }
353    model.delete_bodies(tools).with_context(&ctx)?;
354    for (made_by, edges) in stretches {
355        for (k, &edge) in edges.iter().enumerate() {
356            let at = match edges.len() {
357                1 => made_by.clone(),
358                _ => format!("{made_by},{k}"),
359            };
360            model.rename(edge, namer.name(&[&at])).with_context(&ctx)?;
361            for &(piece, _) in split_along.iter().filter(|&&(_, e)| e == edge) {
362                model
363                    .rename(piece, namer.piece(&face_name, &at))
364                    .with_context(&ctx)?;
365            }
366        }
367    }
368    Ok(curves.into_iter().map(|(_, edge, _)| edge).collect())
369}
370
371#[cfg(test)]
372mod tests {
373    use super::{imprint, imprint_on_face};
374    use crate::remesh::RemeshParams;
375    use geop_core_math::{
376        geop_error::GeopResult, primitives::CoordinateSystem, scalars::Scalar, vector::Vector2,
377    };
378    use geop_core_topology::{Body, EdgeId, SolidId};
379    use geop_lib_sweep::{
380        SweepLoop, extrude, polygon, polyline,
381        shapes::{Axis, block, revolved_cylinder_along_axis, v3},
382    };
383    use geop_ops_model::Profile;
384    use geop_ops_model::{NamedModel, Namer, testing::assert_valid};
385
386    fn v2s(points: &[[f64; 2]]) -> Vec<Vector2> {
387        points
388            .iter()
389            .map(|p| Vector2::from_array([Scalar::from_f64(p[0]), Scalar::from_f64(p[1])]))
390            .collect()
391    }
392
393    /// Imprints onto the face named `target` of the solid `build` makes
394    /// the profile `profile`, drawn in the plane `z = 2` and swept down to
395    /// `z = -3`, through the whole solid.
396    fn imprint_swept(
397        build: impl Fn(&mut NamedModel) -> SolidId,
398        target: &str,
399        profile: Profile,
400    ) -> GeopResult<(NamedModel, Vec<EdgeId>)> {
401        let mut model = NamedModel::new();
402        build(&mut model);
403        let target = model.face_id(target)?;
404        let plane = CoordinateSystem::world_at(v3(0.0, 0.0, 2.0));
405        let namer = Namer::new("project", "p")?;
406        let swept = extrude(
407            &mut model,
408            &namer,
409            None,
410            &plane,
411            &[Scalar::from_f64(-5.0), Scalar::from_f64(1.0)],
412            &[SweepLoop::plain(profile)],
413        )?;
414        let edges = imprint_on_face(
415            &mut model,
416            &namer,
417            target,
418            &[swept.body()?],
419            RemeshParams::default(),
420        )?;
421        Ok((model, edges))
422    }
423
424    fn cube(model: &mut NamedModel) -> SolidId {
425        block(model, "c", [0.0; 3], [1.0; 3])
426    }
427
428    /// The unit cube's top face, at `z = 1`.
429    const TOP: &str = "cube(c,start)";
430
431    fn chain(points: &[[f64; 2]]) -> Profile {
432        Profile::open(polyline(&v2s(points)).unwrap())
433    }
434
435    /// A line across the top face of a cube divides that face in two, and
436    /// only that face: the bottom face, which the sweep crosses too, stays
437    /// whole.
438    #[test]
439    fn a_line_across_a_face_divides_it_in_two() {
440        let (model, edges) = imprint_swept(cube, TOP, chain(&[[0.3, -1.0], [0.3, 2.0]])).unwrap();
441        assert_valid(&model);
442        assert_eq!(edges.len(), 1);
443        assert_eq!(model.topology().solids.len(), 1);
444        assert!(model.sheet_face_names().is_empty(), "the sweep is gone");
445        // Six faces and one more; twelve edges, two of them split, and the
446        // one imprinted.
447        assert_eq!(model.topology().faces.len(), 7);
448        assert_eq!(model.topology().edges.len(), 15);
449        assert_eq!(model.topology().vertices.len(), 10);
450    }
451
452    /// A bent chain whose corner lies on the face divides it along both
453    /// of its legs.
454    #[test]
455    fn a_bent_chain_divides_a_face_along_both_legs() {
456        let (model, edges) =
457            imprint_swept(cube, TOP, chain(&[[0.3, -1.0], [0.5, 0.5], [2.0, 0.6]])).unwrap();
458        assert_valid(&model);
459        assert_eq!(edges.len(), 2);
460        assert_eq!(model.topology().faces.len(), 7);
461    }
462
463    /// A square inside the face closes on itself: it bounds a new face, a
464    /// hole in what is left of the old one.
465    #[test]
466    fn a_closed_loop_inside_a_face_cuts_out_a_face() {
467        let square = polygon(&v2s(&[[0.2, 0.2], [0.6, 0.2], [0.6, 0.6], [0.2, 0.6]])).unwrap();
468        let (model, edges) = imprint_swept(cube, TOP, Profile::closed(square)).unwrap();
469        assert_valid(&model);
470        assert_eq!(edges.len(), 4);
471        assert_eq!(model.topology().faces.len(), 7);
472        let top = model
473            .topology()
474            .get_face(model.face_id(TOP).unwrap())
475            .unwrap();
476        assert_eq!(top.holes.len(), 1);
477    }
478
479    /// A line drawn slanting across a cylinder lying along `x`, seen from
480    /// above, projects onto the quarter of its side facing up and towards
481    /// `+y` as an arc of an ellipse, from where that quarter meets the next
482    /// one to the cylinder's end: it divides that quarter in two, splitting
483    /// the edge between the quarters where it ends on it.
484    #[test]
485    fn a_slanted_line_divides_a_round_face_along_an_ellipse() {
486        let cylinder = |model: &mut NamedModel| {
487            revolved_cylinder_along_axis(
488                model,
489                "y",
490                v3(-0.5, 0.0, 0.0),
491                Scalar::from_f64(0.5),
492                Scalar::ONE,
493                Axis::X,
494            )
495            .unwrap()
496        };
497        let (model, edges) = imprint_swept(
498            cylinder,
499            "cylinder(y,c1,q0)",
500            chain(&[[-1.0, -0.3], [1.0, 0.3]]),
501        )
502        .unwrap();
503        assert_valid(&model);
504        assert_eq!(edges.len(), 1);
505        // Four quarters of the side and four wedges in each cap, and the
506        // piece split off.
507        assert_eq!(model.topology().faces.len(), 13);
508    }
509
510    /// A line ending inside the face divides nothing, and is refused.
511    #[test]
512    fn a_line_ending_inside_the_face_is_refused() {
513        let error =
514            imprint_swept(cube, TOP, chain(&[[0.3, -1.0], [0.3, 0.5]])).expect_err("refused");
515        assert!(error.root_message().contains("ends inside"), "{error:?}");
516    }
517
518    /// A line beside the face misses it, and is refused.
519    #[test]
520    fn a_line_beside_the_face_is_refused() {
521        let error =
522            imprint_swept(cube, TOP, chain(&[[3.0, -1.0], [3.0, 2.0]])).expect_err("refused");
523        assert!(
524            error.root_message().contains("nothing crosses"),
525            "{error:?}"
526        );
527    }
528
529    /// Two unit cubes, the second moved by half along each axis, imprinted
530    /// on each other: three faces of each are divided where the other
531    /// crosses them, both stay solids, and each runs along its own copy of
532    /// the six curves.
533    #[test]
534    fn two_overlapping_cubes_divide_each_other() {
535        let mut model = NamedModel::new();
536        let a = block(&mut model, "a", [0.0; 3], [1.0; 3]);
537        let b = block(&mut model, "b", [0.5; 3], [1.5; 3]);
538        let namer = Namer::new("imprint", "i").unwrap();
539        let edges = imprint(
540            &mut model,
541            &namer,
542            a.into(),
543            b.into(),
544            RemeshParams::default(),
545        )
546        .unwrap();
547        assert_valid(&model);
548        assert_eq!(edges.len(), 6);
549        assert_eq!(model.solid_names(), ["cube(a)", "cube(b)"]);
550        let topology = model.topology();
551        let faces = |name: &str| {
552            let solid = model.solid_id(name).unwrap();
553            topology.solid_faces(solid).unwrap().len()
554        };
555        assert_eq!((faces("cube(a)"), faces("cube(b)")), (9, 9));
556        // Twelve edges of each cube, six of them split, and six curves.
557        assert_eq!(topology.edges.len(), 2 * (12 + 3 + 6));
558    }
559
560    /// Bodies apart imprint nothing, and are refused, left as they were.
561    #[test]
562    fn bodies_apart_are_refused() {
563        let mut model = NamedModel::new();
564        let a = block(&mut model, "a", [0.0; 3], [1.0; 3]);
565        let b = block(&mut model, "b", [2.0; 3], [3.0; 3]);
566        let before = model.clone();
567        let namer = Namer::new("imprint", "i").unwrap();
568        let error = imprint(
569            &mut model,
570            &namer,
571            a.into(),
572            b.into(),
573            RemeshParams::default(),
574        )
575        .unwrap_err();
576        assert!(error.root_message().contains("do not meet"), "{error:?}");
577        assert_eq!(model.topology().faces.len(), before.topology().faces.len());
578    }
579
580    /// A solid crossing a face divides only that face, around where the
581    /// solid's boundary crosses it: a block standing through a cube's top
582    /// cuts a square out of it, and the solid is consumed.
583    #[test]
584    fn a_solid_standing_through_a_face_cuts_a_square_out_of_it() {
585        let mut model = NamedModel::new();
586        cube(&mut model);
587        let top = model.face_id(TOP).unwrap();
588        let tool = block(&mut model, "t", [0.2, 0.2, 0.5], [0.6, 0.6, 1.5]);
589        let namer = Namer::new("imprint", "i").unwrap();
590        let edges = imprint_on_face(
591            &mut model,
592            &namer,
593            top,
594            &[Body::Solid(tool)],
595            RemeshParams::default(),
596        )
597        .unwrap();
598        assert_valid(&model);
599        assert_eq!(edges.len(), 4);
600        assert_eq!(model.solid_names(), ["cube(c)"]);
601        assert_eq!(model.topology().faces.len(), 7);
602    }
603}