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, max_nodes, min_subdivision_size)
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}