Skip to main content

geop_core_topology/edit/
splice_edge_into_face.rs

1use geop_core_math::{
2    geop_error::{GeopError, GeopResult, WithContext},
3    polygon::polygon_signed_area,
4    scalars::Scalar,
5    vector::Vector2,
6};
7
8use crate::{
9    Coedge, CoedgeGeometry, CoedgeId, EdgeId, Face, FaceId, Model, Sense, VertexId,
10    boundary::{BoundaryIndex, BoundaryType},
11    contains::face::{PointClassification, face_contains},
12    loop_sampling::sample_loop_to_polygon,
13};
14
15impl<S: Scalar> Model<S> {
16    /// Splice an **already-existing** `edge_id` into `face_id`'s boundary
17    /// structure, as a forward/reversed coedge pair whose pcurves it fits onto
18    /// the face's own surface.
19    ///
20    /// Unlike the `mer`/`mekr` Euler operators — which mint their own brand-new
21    /// edge, and so can't be used for an edge that already exists because two
22    /// faces are meant to *share* it (the whole point of imprinting an
23    /// intersection curve) — this rewires loops around an edge it's handed.
24    ///
25    /// What it does depends on how much of the edge's own topology the face
26    /// already knows about, which is the only thing that determines what a
27    /// correct result even looks like:
28    ///
29    /// | edge's start/end vertex on this face's boundary | result |
30    /// |---|---|
31    /// | neither | a new self-contained ring floating inside the face — a **hole** |
32    /// | exactly one | a spur (out along the edge and back) inserted into that vertex's own loop |
33    /// | both, same hole | that hole is **divided into two holes** |
34    /// | both, two different holes | those holes are **merged into one** |
35    /// | both, a hole and the outer loop | the hole is **absorbed** into the outer loop |
36    /// | both, the outer loop | the face is **split into two faces** |
37    ///
38    /// Splitting the face, and dividing a hole (whose material side becomes
39    /// a face of its own), are the cases that create a face; its id is
40    /// returned, `None` otherwise. The face split is the only one that has to
41    /// reclassify the old face's holes — each now lies inside exactly one of
42    /// the two halves (see [`split_face`]). The middle cases are the same
43    /// restructurings `mer`/`mekr` perform; a spur changes no connectivity at
44    /// all, being "wire" topology rather than a real trim boundary.
45    pub fn splice_edge_into_face(
46        &mut self,
47        edge_id: EdgeId,
48        face_id: FaceId,
49        max_nodes: usize,
50        min_subdivision_size: S,
51    ) -> GeopResult<Option<FaceId>> {
52        let model = self;
53        let ctx = |e: GeopError| {
54            e.with_context(format!(
55                "Model::splice_edge_into_face(edge={edge_id}, face={face_id})"
56            ))
57        };
58
59        let edge = model.get_edge(edge_id).with_context(&ctx)?;
60        let (start_vertex, end_vertex) = (edge.start_vertex, edge.end_vertex);
61        let curve = edge.curve.clone();
62
63        // The coedge each of the edge's endpoints would attach *after*: the one
64        // already arriving at that vertex.
65        let at_start = coedge_ending_at(model, face_id, start_vertex).with_context(&ctx)?;
66        let at_end = coedge_ending_at(model, face_id, end_vertex).with_context(&ctx)?;
67
68        // Where the face already has a coedge arriving at an endpoint, that
69        // coedge's own pcurve end is the authoritative `(u, v)` there — pin the
70        // new pcurve to it so the loop stays exactly continuous.
71        let pin_start = pcurve_end_uv(model, at_start).with_context(&ctx)?;
72        let pin_end = pcurve_end_uv(model, at_end).with_context(&ctx)?;
73        let surface = model.get_face(face_id).with_context(&ctx)?.surface.clone();
74        let pcurve_fwd = surface
75            .fit_pcurve(&curve, pin_start, pin_end)
76            .with_context(&ctx)?;
77        let pcurve_rev = pcurve_fwd.reverse();
78
79        let fwd = model.insert_coedge(Coedge {
80            geometry: CoedgeGeometry::Edge(edge_id),
81            sense: Sense::Forward,
82            pcurve: pcurve_fwd,
83            next: CoedgeId(0),
84            prev: CoedgeId(0),
85            face: face_id,
86        });
87        let rev = model.insert_coedge(Coedge {
88            geometry: CoedgeGeometry::Edge(edge_id),
89            sense: Sense::Reversed,
90            pcurve: pcurve_rev,
91            next: CoedgeId(0),
92            prev: CoedgeId(0),
93            face: face_id,
94        });
95
96        let mut new_face = None;
97        match (at_start, at_end) {
98            // Neither endpoint is on this face yet: a self-contained
99            // two-coedge ring, floating inside the face — that is a hole.
100            (None, None) => {
101                link(model, fwd, rev).with_context(&ctx)?;
102                link(model, rev, fwd).with_context(&ctx)?;
103                model
104                    .get_face_mut(face_id)
105                    .with_context(&ctx)?
106                    .holes
107                    .push(BoundaryType::Loop(fwd));
108            }
109            // Only one endpoint is on the face: splice a spur into that loop —
110            // out along the edge and straight back, so the loop still closes.
111            (Some(a), None) => {
112                splice_spur(model, a, fwd, rev).with_context(&ctx)?;
113            }
114            (None, Some(a)) => {
115                splice_spur(model, a, rev, fwd).with_context(&ctx)?;
116            }
117            // Both endpoints are already on the face, so the new edge joins
118            // two points of its boundary structure. *Which* boundaries they
119            // sit on decides what that means topologically, and the four
120            // cases are genuinely different operations.
121            (Some(a), Some(b)) => {
122                let loop_a = model
123                    .find_boundary_containing(face_id, a)
124                    .with_context(&ctx)?;
125                let loop_b = model
126                    .find_boundary_containing(face_id, b)
127                    .with_context(&ctx)?;
128
129                // `fwd` runs start->end, so it leaves the coedge arriving at
130                // `start_vertex` and lands on whatever leaves `end_vertex`;
131                // `rev` closes the complementary path the other way round.
132                let a_next = model.get_coedge(a).with_context(&ctx)?.next;
133                let b_next = model.get_coedge(b).with_context(&ctx)?.next;
134
135                link(model, a, fwd).with_context(&ctx)?;
136                link(model, fwd, b_next).with_context(&ctx)?;
137                link(model, b, rev).with_context(&ctx)?;
138                link(model, rev, a_next).with_context(&ctx)?;
139
140                match (loop_a, loop_b) {
141                    // Two points of the *outer* loop: the edge cuts the face
142                    // itself in two. Both rings bound material, so neither can
143                    // be a hole of the other — this is the only case that
144                    // creates a face.
145                    (BoundaryIndex::Outer, BoundaryIndex::Outer) => {
146                        // Both halves must enclose material. A half of zero
147                        // area means the new edge runs along the stretch of
148                        // boundary between its own endpoints — it duplicates
149                        // a path the face already has, so "splitting" there
150                        // carves off a sliver rather than two faces. Reported
151                        // rather than built: a zero-area face is structurally
152                        // perfect and every other check accepts it, so it
153                        // would surface much later as a face nothing can be
154                        // classified against.
155                        for ring in [fwd, rev] {
156                            let area = signed_area(model, ring).with_context(&ctx)?;
157                            if !area.abs().definitely_greater(S::ZERO) {
158                                return Err(ctx(GeopError::new(format!(
159                                    "{DEGENERATE_SPLIT}: splicing edge {edge_id} into face {face_id} would split its outer loop into a ring of signed area {area:?} — the edge runs along the boundary it is being spliced into, so one side encloses nothing"
160                                ))));
161                            }
162                        }
163                        new_face = Some(
164                            split_face(model, face_id, fwd, rev, max_nodes, min_subdivision_size)
165                                .with_context(&ctx)?,
166                        );
167                    }
168                    // Two points of the *same* hole. The edge runs through
169                    // material, so together with one of the two arcs it just
170                    // cut the hole into, it encloses a patch of material that
171                    // is now bounded on its own — a new face — while the other
172                    // arc still bounds a void and stays a hole.
173                    //
174                    // Which is which is exactly the winding: by this kernel's
175                    // convention an outer loop runs counter-clockwise in
176                    // `(u, v)` and a hole runs clockwise, so the ring with
177                    // positive signed area bounds material and the one with
178                    // negative area bounds a void. Nothing weaker will do —
179                    // both rings pass through the same vertices and contain
180                    // the same edge, so no purely topological test can tell
181                    // them apart.
182                    (BoundaryIndex::Hole(i), BoundaryIndex::Hole(j)) if i == j => {
183                        let area = signed_area(model, fwd).with_context(&ctx)?;
184                        let (material, void) = if area.definitely_greater(S::ZERO) {
185                            (fwd, rev)
186                        } else if area.definitely_less(S::ZERO) {
187                            (rev, fwd)
188                        } else {
189                            return Err(ctx(GeopError::new(format!(
190                                "splice_edge_into_face: splitting hole {i} of face {face_id} with edge {edge_id} produced a ring of signed area {area:?}, which is not decidably clockwise or counter-clockwise — the split would be degenerate"
191                            ))));
192                        };
193                        model
194                            .get_face_mut(face_id)
195                            .with_context(&ctx)?
196                            .set_boundary(BoundaryIndex::Hole(i), BoundaryType::Loop(void));
197                        new_face = Some(
198                            new_face_from_ring(
199                                model,
200                                face_id,
201                                material,
202                                max_nodes,
203                                min_subdivision_size,
204                            )
205                            .with_context(&ctx)?,
206                        );
207                    }
208                    // Two *different* holes: the bridge merges them into one
209                    // hole.
210                    (BoundaryIndex::Hole(i), BoundaryIndex::Hole(j)) => {
211                        let face = model.get_face_mut(face_id).with_context(&ctx)?;
212                        face.set_boundary(BoundaryIndex::Hole(i), BoundaryType::Loop(fwd));
213                        face.holes.remove(j);
214                    }
215                    // A hole bridged to the outer loop: the hole stops being a
216                    // separate boundary and its coedges become part of the one
217                    // ring that now bounds the face.
218                    (BoundaryIndex::Outer, BoundaryIndex::Hole(j))
219                    | (BoundaryIndex::Hole(j), BoundaryIndex::Outer) => {
220                        let face = model.get_face_mut(face_id).with_context(&ctx)?;
221                        face.outer = BoundaryType::Loop(fwd);
222                        face.holes.remove(j);
223                    }
224                }
225            }
226        }
227
228        Ok(new_face)
229    }
230}
231
232/// The `(u, v)` a coedge's pcurve ends at, if there is such a coedge.
233fn pcurve_end_uv<S: Scalar>(
234    model: &Model<S>,
235    coedge: Option<CoedgeId>,
236) -> GeopResult<Option<Vector2<S>>> {
237    let Some(coedge) = coedge else {
238        return Ok(None);
239    };
240    let pcurve = &model.get_coedge(coedge)?.pcurve;
241    let (_, t1) = pcurve.domain();
242    Ok(Some(pcurve.evaluate(t1)?))
243}
244
245/// The coedge of `face_id` that *arrives* at `vertex`, if any — the one a
246/// new edge leaving `vertex` has to be spliced in after.
247fn coedge_ending_at<S: Scalar>(
248    model: &Model<S>,
249    face_id: FaceId,
250    vertex: VertexId,
251) -> GeopResult<Option<CoedgeId>> {
252    for coedge_id in model.iterate_face_coedges(face_id) {
253        if model.coedge_end_vertex_id(coedge_id)? == vertex {
254            return Ok(Some(coedge_id));
255        }
256    }
257    Ok(None)
258}
259
260/// Make `to` follow `from` in loop order, keeping `prev` consistent.
261fn link<S: Scalar>(model: &mut Model<S>, from: CoedgeId, to: CoedgeId) -> GeopResult<()> {
262    model.get_coedge_mut(from)?.next = to;
263    model.get_coedge_mut(to)?.prev = from;
264    Ok(())
265}
266
267/// Insert `out`/`back` (an out-and-return pair on the same edge) into the
268/// loop right after `after`, leaving the rest of the loop untouched.
269fn splice_spur<S: Scalar>(
270    model: &mut Model<S>,
271    after: CoedgeId,
272    out: CoedgeId,
273    back: CoedgeId,
274) -> GeopResult<()> {
275    let after_next = model.get_coedge(after)?.next;
276    link(model, after, out)?;
277    link(model, out, back)?;
278    link(model, back, after_next)?;
279    Ok(())
280}
281
282/// Marks the error raised when a splice would divide a face into a piece of
283/// no area. Callers that are *imprinting* an edge they already know follows
284/// this face's boundary can treat it as "already represented" rather than a
285/// failure — see `booleans::remesh`'s tracing. Matched on the message because
286/// `GeopError` carries no code; that is fragile enough to be worth the
287/// constant rather than a literal at both ends.
288pub const DEGENERATE_SPLIT: &str = "degenerate split";
289
290/// Cut `face_id` in two along the newly inserted edge: `keep` and `moved`
291/// anchor the two rings the old outer loop just split into.
292///
293/// `keep`'s ring stays with `face_id`; `moved`'s ring becomes the outer loop
294/// of a brand-new face on the same surface and in the same shell. Both rings
295/// bound material — that is what distinguishes this from every other case in
296/// `splice_edge_into_face` — so neither can become a hole of the other.
297///
298/// The old face's holes then have to be re-sorted, since each now lies inside
299/// exactly one of the two faces and the split has no idea which. Each hole is
300/// classified by taking a point on it and asking `face_contains` — a hole that
301/// the new face contains moves there, everything else stays. A hole with no
302/// usable point (a bare `Vertex` boundary, or a loop whose `(u, v)` cannot be
303/// read) stays put rather than being dropped: leaving it on the wrong face is
304/// a recoverable error, losing it silently fills in a hole that should exist.
305fn split_face<S: Scalar>(
306    model: &mut Model<S>,
307    face_id: FaceId,
308    keep: CoedgeId,
309    moved: CoedgeId,
310    max_nodes: usize,
311    min_subdivision_size: S,
312) -> GeopResult<FaceId> {
313    model
314        .get_face_mut(face_id)?
315        .set_boundary(BoundaryIndex::Outer, BoundaryType::Loop(keep));
316    new_face_from_ring(model, face_id, moved, max_nodes, min_subdivision_size)
317}
318
319/// Split `ring` off `face_id` as the outer loop of a brand-new face on the
320/// same surface and in the same shell.
321///
322/// `face_id`'s own holes are then re-sorted, since each now lies inside
323/// exactly one of the two faces and the split has no idea which. Each is
324/// classified by taking a point on it and asking `face_contains` — a hole the
325/// new face contains moves there, everything else stays. A hole with no
326/// usable point (a bare `Vertex` boundary, or a loop whose `(u, v)` cannot be
327/// read) stays put rather than being dropped: leaving it on the wrong face is
328/// a recoverable error, losing it silently fills in a hole that should exist.
329fn new_face_from_ring<S: Scalar>(
330    model: &mut Model<S>,
331    face_id: FaceId,
332    ring: CoedgeId,
333    max_nodes: usize,
334    min_subdivision_size: S,
335) -> GeopResult<FaceId> {
336    let old = model.get_face(face_id)?.clone();
337    let new_face_id = model.insert_face(Face {
338        surface: old.surface.clone(),
339        outer: BoundaryType::Loop(ring),
340        holes: Vec::new(),
341        shell: old.shell,
342    });
343    model.get_shell_mut(old.shell)?.faces.push(new_face_id);
344
345    // Every coedge of the moved ring now belongs to the new face. Walked with
346    // a hard cap: a ring that never returns to its anchor would otherwise spin
347    // here forever, and a corrupted `next` chain is exactly the kind of thing
348    // a restructuring like this can introduce.
349    for c in ring_coedges(model, ring)? {
350        model.get_coedge_mut(c)?.face = new_face_id;
351    }
352
353    let mut stay = Vec::new();
354    let mut move_over = Vec::new();
355    for hole in old.holes {
356        let Some(uv) = boundary_uv(model, hole)? else {
357            stay.push(hole);
358            continue;
359        };
360        let inside_new = matches!(
361            face_contains(
362                model,
363                new_face_id,
364                uv[0],
365                uv[1],
366                max_nodes,
367                min_subdivision_size,
368                HOLE_CLASSIFY_SEED,
369            )?,
370            PointClassification::Inside
371        );
372        if inside_new {
373            move_over.push(hole);
374        } else {
375            stay.push(hole);
376        }
377    }
378    for hole in &move_over {
379        if let BoundaryType::Loop(anchor) = hole {
380            for c in ring_coedges(model, *anchor)? {
381                model.get_coedge_mut(c)?.face = new_face_id;
382            }
383        }
384    }
385    model.get_face_mut(face_id)?.holes = stay;
386    model.get_face_mut(new_face_id)?.holes = move_over;
387    Ok(new_face_id)
388}
389
390/// The signed area, in `(u, v)`, of the ring anchored at `anchor`: positive
391/// counter-clockwise, negative clockwise.
392///
393/// Sampled through the same `sample_loop_to_polygon` the rasterizer uses, so
394/// there is one notion of "what polygon does this loop trace" rather than two
395/// that could drift apart.
396fn signed_area<S: Scalar>(model: &Model<S>, anchor: CoedgeId) -> GeopResult<S> {
397    let polygon = sample_loop_to_polygon(model, anchor, LOOP_AREA_SAMPLES)?;
398    Ok(polygon_signed_area(&polygon))
399}
400
401/// Samples per coedge for [`signed_area`]. Only the *sign* is used, and a
402/// curved trim loop needs a few samples per coedge for that sign to be right;
403/// this bounds effort, not correctness — an undecidable sign is reported as
404/// an error rather than guessed.
405const LOOP_AREA_SAMPLES: usize = 8;
406
407/// Every coedge of the ring anchored at `anchor`, erroring rather than
408/// spinning if the `next` chain never returns to it.
409fn ring_coedges<S: Scalar>(model: &Model<S>, anchor: CoedgeId) -> GeopResult<Vec<CoedgeId>> {
410    let cap = model.coedges.len() + 1;
411    let ring: Vec<CoedgeId> = model.iterate_loop_coedges(anchor).take(cap).collect();
412    if ring.len() >= cap {
413        return Err(GeopError::new(format!(
414            "splice_edge_into_face: the ring anchored at coedge {anchor} never returns to its anchor"
415        )));
416    }
417    Ok(ring)
418}
419
420/// Fixed seed for the ray casting behind hole classification — `face_contains`
421/// retries until it finds a ray grazing nothing, so the answer is
422/// seed-independent and a constant keeps a split reproducible run to run.
423const HOLE_CLASSIFY_SEED: u64 = 0x1234_5678_9ABC_DEF0;
424
425/// A `(u, v)` on `boundary`, for classifying which side of a split it falls
426/// on. `None` for a bare `Vertex` boundary, which has no pcurve to read one
427/// from.
428fn boundary_uv<S: Scalar>(
429    model: &Model<S>,
430    boundary: BoundaryType,
431) -> GeopResult<Option<geop_core_math::vector::Vector2<S>>> {
432    let BoundaryType::Loop(anchor) = boundary else {
433        return Ok(None);
434    };
435    let pcurve = &model.get_coedge(anchor)?.pcurve;
436    let (t0, _) = pcurve.domain();
437    Ok(Some(pcurve.evaluate(t0)?))
438}