geop_core_topology/edit/splice_edge_into_face.rs
1use geop_core_math::{
2 geop_error::{GeopError, GeopResult, WithContext},
3 scalars::Scalar,
4 solvers::bisect::bisect,
5 vector::Vector2,
6};
7
8use crate::{
9 Coedge, CoedgeGeometry, CoedgeId, Curve2, EdgeId, Face, FaceId, Model, Sense, VertexId,
10 boundary::{BoundaryIndex, BoundaryType},
11 contains::face::{PointClassification, face_contains},
12 uv_loop::loop_signed_area,
13};
14
15impl Model {
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 /// The edge already exists because two faces are meant to *share* it
21 /// (the whole point of imprinting an intersection curve), so this
22 /// rewires loops around the edge it's handed rather than making one.
23 ///
24 /// What it does depends on how much of the edge's own topology the face
25 /// already knows about, which is the only thing that determines what a
26 /// correct result even looks like:
27 ///
28 /// | edge's start/end vertex on this face's boundary | result |
29 /// |---|---|
30 /// | neither | a new self-contained ring floating inside the face — a **hole** |
31 /// | exactly one | a spur (out along the edge and back) inserted into that vertex's own loop |
32 /// | both, same hole | that hole is **divided into two holes** |
33 /// | both, two different holes | those holes are **merged into one** |
34 /// | both, a hole and the outer loop | the hole is **absorbed** into the outer loop |
35 /// | both, the outer loop | the face is **split into two faces** |
36 ///
37 /// Splitting the face, and dividing a hole (whose material side becomes
38 /// a face of its own), are the cases that create a face; its id is
39 /// returned, `None` otherwise. The face split is the only one that has to
40 /// reclassify the old face's holes — each now lies inside exactly one of
41 /// the two halves (see [`split_face`]). A spur changes no connectivity at
42 /// all, being "wire" topology rather than a real trim boundary.
43 pub fn splice_edge_into_face(
44 &mut self,
45 edge_id: EdgeId,
46 face_id: FaceId,
47 max_nodes: usize,
48 min_subdivision_size: Scalar,
49 ) -> GeopResult<Option<FaceId>> {
50 let model = self;
51 let ctx = |e: GeopError| {
52 e.with_context(format!(
53 "Model::splice_edge_into_face(edge={edge_id}, face={face_id})"
54 ))
55 };
56
57 let edge = model.get_edge(edge_id).with_context(&ctx)?;
58 let (start_vertex, end_vertex) = (edge.start_vertex, edge.end_vertex);
59 let curve = edge.curve.clone();
60
61 // The coedge each of the edge's endpoints would attach *after*: one
62 // already arriving at that vertex. Where the face already has one,
63 // its pcurve end is the authoritative `(u, v)` there — the new pcurve
64 // is pinned to it so the loop stays exactly continuous.
65 let surface = model.get_face(face_id).with_context(&ctx)?.surface.clone();
66 let fit = |model: &Model, at_start: Option<CoedgeId>, at_end: Option<CoedgeId>| {
67 surface
68 .fit_pcurve(
69 &curve,
70 pin(model, &surface, at_start, start_vertex)?,
71 pin(model, &surface, at_end, end_vertex)?,
72 max_nodes,
73 min_subdivision_size,
74 )
75 .with_context(&ctx)
76 };
77 let mut arriving_start =
78 coedges_ending_at(model, face_id, start_vertex).with_context(&ctx)?;
79 let mut arriving_end = coedges_ending_at(model, face_id, end_vertex).with_context(&ctx)?;
80 let first = |arriving: &[CoedgeId]| arriving.first().copied();
81 // A pole is a whole row of the surface at one vertex: no one corner
82 // of the loop is where an edge arrives at it, so the first fit pins
83 // neither end there, and the end's own `(u, v)` says where along the
84 // row it is (see [`split_pole_row`]).
85 let pole = |model: &Model, vertex: VertexId, arriving: &[CoedgeId]| {
86 arriving.iter().any(|&c| {
87 model
88 .get_coedge(c)
89 .is_ok_and(|c| c.geometry == CoedgeGeometry::Vertex(vertex))
90 })
91 };
92 let (pole_start, pole_end) = (
93 pole(model, start_vertex, &arriving_start),
94 pole(model, end_vertex, &arriving_end),
95 );
96 let mut pcurve_fwd = fit(
97 model,
98 first(&arriving_start).filter(|_| !pole_start),
99 first(&arriving_end).filter(|_| !pole_end),
100 )?;
101 if pole_start || pole_end {
102 let (t0, t1) = pcurve_fwd.domain();
103 for (is_pole, vertex, t, arriving) in [
104 (pole_start, start_vertex, t0, &mut arriving_start),
105 (pole_end, end_vertex, t1, &mut arriving_end),
106 ] {
107 if !is_pole {
108 continue;
109 }
110 let uv = pcurve_fwd.evaluate(t).with_context(&ctx)?;
111 if let Some(corner) =
112 split_pole_row(model, vertex, arriving, uv).with_context(&ctx)?
113 {
114 *arriving = vec![corner];
115 }
116 }
117 pcurve_fwd = fit(model, first(&arriving_start), first(&arriving_end))?;
118 }
119 // A vertex the loop passes more than once — the root of a spur, or a
120 // point where the face touches itself — has as many corners there,
121 // and the edge belongs in the one it leaves into. Its pcurve says
122 // which, so it is chosen after a first fit and refitted if that
123 // moved the pin.
124 let at_start = choose_corner(
125 model,
126 &arriving_start,
127 Leg {
128 pcurve: &pcurve_fwd,
129 from_end: false,
130 },
131 )
132 .with_context(&ctx)?;
133 let at_end = choose_corner(
134 model,
135 &arriving_end,
136 Leg {
137 pcurve: &pcurve_fwd,
138 from_end: true,
139 },
140 )
141 .with_context(&ctx)?;
142 if at_start != first(&arriving_start) || at_end != first(&arriving_end) {
143 pcurve_fwd = fit(model, at_start, at_end)?;
144 }
145 let pcurve_rev = pcurve_fwd.reverse();
146
147 // A spur dangles into the face: its free end has to lie strictly
148 // inside it. Were it outside, the face's loop would run out of the
149 // face and back — every check of the loop's own structure passes, and
150 // the face is left straddling whatever the edge was meant to divide
151 // (`narrow_groove` surfaced that far downstream, in classification).
152 // On the boundary, it would be a vertex of the loop already.
153 if let (Some(_), None) | (None, Some(_)) = (at_start, at_end) {
154 let (t0, t1) = pcurve_fwd.domain();
155 let (free, t) = if at_start.is_some() {
156 (end_vertex, t1)
157 } else {
158 (start_vertex, t0)
159 };
160 let uv = pcurve_fwd.evaluate(t).with_context(&ctx)?;
161 let class = face_contains(
162 model,
163 face_id,
164 uv[0],
165 uv[1],
166 max_nodes,
167 min_subdivision_size,
168 HOLE_CLASSIFY_SEED,
169 )
170 .with_context(&ctx)?;
171 if class != PointClassification::Inside {
172 let point = model.get_vertex(free).with_context(&ctx)?.point;
173 return Err(ctx(GeopError::new(format!(
174 "a spur's free end must lie inside the face, but vertex {free} at {point:?} (uv={uv:?}) is {class:?}; the face is bounded by (corners, outer loop first) {:?}",
175 model.face_corners(face_id).with_context(&ctx)?
176 ))));
177 }
178 }
179
180 let fwd = model.insert_coedge(Coedge {
181 geometry: CoedgeGeometry::Edge(edge_id),
182 sense: Sense::Forward,
183 pcurve: pcurve_fwd,
184 next: CoedgeId(0),
185 prev: CoedgeId(0),
186 face: face_id,
187 });
188 let rev = model.insert_coedge(Coedge {
189 geometry: CoedgeGeometry::Edge(edge_id),
190 sense: Sense::Reversed,
191 pcurve: pcurve_rev,
192 next: CoedgeId(0),
193 prev: CoedgeId(0),
194 face: face_id,
195 });
196
197 let mut new_face = None;
198 match (at_start, at_end) {
199 // Neither endpoint is on this face yet: a self-contained
200 // two-coedge ring, floating inside the face — that is a hole.
201 (None, None) => {
202 link(model, fwd, rev).with_context(&ctx)?;
203 link(model, rev, fwd).with_context(&ctx)?;
204 model
205 .get_face_mut(face_id)
206 .with_context(&ctx)?
207 .holes
208 .push(BoundaryType::Loop(fwd));
209 }
210 // Only one endpoint is on the face: splice a spur into that loop —
211 // out along the edge and straight back, so the loop still closes.
212 (Some(a), None) => {
213 splice_spur(model, a, fwd, rev).with_context(&ctx)?;
214 }
215 (None, Some(a)) => {
216 splice_spur(model, a, rev, fwd).with_context(&ctx)?;
217 }
218 // Both endpoints are already on the face, so the new edge joins
219 // two points of its boundary structure. *Which* boundaries they
220 // sit on decides what that means topologically, and the four
221 // cases are genuinely different operations.
222 (Some(a), Some(b)) => {
223 let loop_a = model
224 .find_boundary_containing(face_id, a)
225 .with_context(&ctx)?;
226 let loop_b = model
227 .find_boundary_containing(face_id, b)
228 .with_context(&ctx)?;
229
230 // `fwd` runs start->end, so it leaves the coedge arriving at
231 // `start_vertex` and lands on whatever leaves `end_vertex`;
232 // `rev` closes the complementary path the other way round.
233 let a_next = model.get_coedge(a).with_context(&ctx)?.next;
234 let b_next = model.get_coedge(b).with_context(&ctx)?.next;
235
236 link(model, a, fwd).with_context(&ctx)?;
237 link(model, fwd, b_next).with_context(&ctx)?;
238 link(model, b, rev).with_context(&ctx)?;
239 link(model, rev, a_next).with_context(&ctx)?;
240
241 match (loop_a, loop_b) {
242 // Two points of the *outer* loop: the edge cuts the face
243 // itself in two. Both rings bound material, so neither can
244 // be a hole of the other — this is the only case that
245 // creates a face.
246 (BoundaryIndex::Outer, BoundaryIndex::Outer) => {
247 // Both halves must enclose material. A half of zero
248 // area means the new edge runs along the stretch of
249 // boundary between its own endpoints — it duplicates
250 // a path the face already has, so "splitting" there
251 // carves off a sliver rather than two faces. Reported
252 // rather than built: a zero-area face is structurally
253 // perfect and every other check accepts it, so it
254 // would surface much later as a face nothing can be
255 // classified against.
256 for ring in [fwd, rev] {
257 let area = loop_signed_area(model, ring).with_context(&ctx)?;
258 if !area.abs().definitely_greater(Scalar::ZERO) {
259 let widest = widest_pcurve(model, ring).with_context(&ctx)?;
260 return Err(ctx(GeopError::new(format!(
261 "{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 (the ring's widest pcurve: {widest})"
262 ))));
263 }
264 }
265 new_face = Some(
266 split_face(model, face_id, fwd, rev, max_nodes, min_subdivision_size)
267 .with_context(&ctx)?,
268 );
269 }
270 // Two points of the *same* hole. The edge runs through
271 // material, so together with one of the two arcs it just
272 // cut the hole into, it encloses a patch of material that
273 // is now bounded on its own — a new face — while the other
274 // arc still bounds a void and stays a hole.
275 //
276 // Which is which is exactly the winding: by this kernel's
277 // convention an outer loop runs counter-clockwise in
278 // `(u, v)` and a hole runs clockwise, so the ring with
279 // positive signed area bounds material and the one with
280 // negative area bounds a void. Nothing weaker will do —
281 // both rings pass through the same vertices and contain
282 // the same edge, so no purely topological test can tell
283 // them apart.
284 (BoundaryIndex::Hole(i), BoundaryIndex::Hole(j)) if i == j => {
285 let area = loop_signed_area(model, fwd).with_context(&ctx)?;
286 let (material, void) = if area.definitely_greater(Scalar::ZERO) {
287 (fwd, rev)
288 } else if area.definitely_less(Scalar::ZERO) {
289 (rev, fwd)
290 } else {
291 return Err(ctx(GeopError::new(format!(
292 "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"
293 ))));
294 };
295 model
296 .get_face_mut(face_id)
297 .with_context(&ctx)?
298 .set_boundary(BoundaryIndex::Hole(i), BoundaryType::Loop(void));
299 new_face = Some(
300 new_face_from_ring(
301 model,
302 face_id,
303 material,
304 max_nodes,
305 min_subdivision_size,
306 )
307 .with_context(&ctx)?,
308 );
309 }
310 // Two *different* holes: the bridge merges them into one
311 // hole.
312 (BoundaryIndex::Hole(i), BoundaryIndex::Hole(j)) => {
313 let face = model.get_face_mut(face_id).with_context(&ctx)?;
314 face.set_boundary(BoundaryIndex::Hole(i), BoundaryType::Loop(fwd));
315 face.holes.remove(j);
316 }
317 // A hole bridged to the outer loop: the hole stops being a
318 // separate boundary and its coedges become part of the one
319 // ring that now bounds the face.
320 (BoundaryIndex::Outer, BoundaryIndex::Hole(j))
321 | (BoundaryIndex::Hole(j), BoundaryIndex::Outer) => {
322 let face = model.get_face_mut(face_id).with_context(&ctx)?;
323 face.outer = BoundaryType::Loop(fwd);
324 face.holes.remove(j);
325 }
326 }
327 }
328 }
329
330 Ok(new_face)
331 }
332}
333
334/// Where an edge arrives at a pole — `vertex`, at which a face's surface
335/// collapses a whole row — if that is in the middle of the row's coedge: the
336/// coedge among `arriving` that runs along the row (see
337/// [`CoedgeGeometry::Vertex`]) is cut at `uv`, where the edge's pcurve ends,
338/// into two, and the first is returned, which now ends there. The edge is
339/// spliced after it, between the two halves of the row.
340///
341/// `None` where no such coedge holds `uv` strictly inside: the edge arrives
342/// at one of the corners of the row, which the usual choice of corner
343/// finds. A pole has a corner for every end of its row and every meridian
344/// that arrives at it, each at a `(u, v)` of its own, though they are one
345/// vertex — so which of them an edge arrives at is told by its `(u, v)`,
346/// not by the way it leaves the vertex.
347///
348/// Which `u` the edge arrives at, along a row that is all one point, is the
349/// edge's own: taken from where its pcurve was fitted to end — sharp, since
350/// the row is cut there, and the edge is then pinned to that cut.
351fn split_pole_row(
352 model: &mut Model,
353 vertex: VertexId,
354 arriving: &[CoedgeId],
355 uv: Vector2,
356) -> GeopResult<Option<CoedgeId>> {
357 for &row in arriving {
358 let coedge = model.get_coedge(row)?;
359 if coedge.geometry != CoedgeGeometry::Vertex(vertex) {
360 continue;
361 }
362 let (t0, t1) = coedge.pcurve.domain();
363 let (a, b) = (coedge.pcurve.evaluate(t0)?, coedge.pcurve.evaluate(t1)?);
364 let (face, next) = (coedge.face, coedge.next);
365 if !uv[1].could_be_equal(a[1]) || !uv[1].could_be_equal(b[1]) {
366 continue;
367 }
368 let u = uv[0].midpoint().sharpen();
369 let (lo, hi) = (a[0].min(b[0]), a[0].max(b[0]));
370 if !u.definitely_greater(lo) || !u.definitely_less(hi) {
371 continue;
372 }
373 // The row runs straight along `u`, at a speed of its own.
374 let t = t0
375 .add(t1.sub(t0).mul(u.sub(a[0]).div(b[0].sub(a[0]))?))
376 .sharpen();
377 let (before, after) = coedge.pcurve.split(t)?;
378 model.get_coedge_mut(row)?.pcurve = before;
379 let rest = model.insert_coedge(Coedge {
380 geometry: CoedgeGeometry::Vertex(vertex),
381 sense: Sense::Forward,
382 pcurve: after,
383 next,
384 prev: row,
385 face,
386 });
387 link(model, row, rest)?;
388 link(model, rest, next)?;
389 return Ok(Some(row));
390 }
391 Ok(None)
392}
393
394/// The `(u, v)` a coedge's pcurve ends at, if there is such a coedge.
395fn pcurve_end_uv(model: &Model, coedge: Option<CoedgeId>) -> GeopResult<Option<Vector2>> {
396 let Some(coedge) = coedge else {
397 return Ok(None);
398 };
399 let pcurve = &model.get_coedge(coedge)?.pcurve;
400 let (_, t1) = pcurve.domain();
401 Ok(Some(pcurve.evaluate(t1)?))
402}
403
404/// The `(u, v)` to pin a new pcurve's end to at `vertex`, where `coedge`
405/// arrives on `surface`: that coedge's end — what keeps the loop continuous
406/// — intersected with `vertex`'s own projection onto the surface. Both are
407/// enclosures of the one `(u, v)` the vertex is at, so their intersection is
408/// too, and it is no wider than the narrower: an end widened by its own
409/// pcurve's history (a pin of its own, the drift it encloses) does not pass
410/// that width on — a pcurve is widened to hold its pins whole, all along
411/// (see `NurbCurve2D::interpolate_enclosing`), so a wide pin is a wide
412/// pcurve. A projection that fails, or misses the end altogether, leaves
413/// the end as it is.
414fn pin(
415 model: &Model,
416 surface: &geop_core_geometry::nurb_surface::NurbSurface3D,
417 coedge: Option<CoedgeId>,
418 vertex: VertexId,
419) -> GeopResult<Option<Vector2>> {
420 let Some(end) = pcurve_end_uv(model, coedge)? else {
421 return Ok(None);
422 };
423 let point = model.get_vertex(vertex)?.point;
424 let Ok((u, v)) = surface.project(point, end[0].midpoint(), end[1].midpoint(), PIN_ITERATIONS)
425 else {
426 return Ok(Some(end));
427 };
428 let overlaps = end[0].could_be_equal(u) && end[1].could_be_equal(v);
429 Ok(Some(if overlaps {
430 Vector2::from_array([end[0].intersect(u), end[1].intersect(v)])
431 } else {
432 end
433 }))
434}
435
436/// Newton iterations refining a pin (see [`pin`]): from a seed already on
437/// the foot point's doorstep, a handful converge.
438const PIN_ITERATIONS: usize = 8;
439
440/// Every coedge of `face_id` that *arrives* at `vertex`: one per time its
441/// boundary passes through it. A new edge leaving `vertex` has to be spliced
442/// in after one of them — see [`choose_corner`].
443fn coedges_ending_at(
444 model: &Model,
445 face_id: FaceId,
446 vertex: VertexId,
447) -> GeopResult<Vec<CoedgeId>> {
448 let mut arriving = Vec::new();
449 for coedge_id in model.iterate_face_coedges(face_id) {
450 if model.coedge_end_vertex_id(coedge_id)? == vertex {
451 arriving.push(coedge_id);
452 }
453 }
454 Ok(arriving)
455}
456
457/// Of the coedges `arriving` at one vertex, the one whose corner a new edge
458/// leaving the vertex along `leaving` runs into.
459///
460/// Each pass of the boundary through the vertex makes a corner: the face's
461/// material fills the angle swept counter-clockwise from the way the boundary
462/// leaves to the way it arrived from — material lies to the left of every
463/// loop, outer and hole alike. One pass leaves no choice. Several do — a
464/// spur's root, where the boundary passes once on each side of it — and
465/// splicing into the wrong corner puts the edge on the wrong side of the
466/// spur: the face split it makes then carries the spur into the half it does
467/// not lie in. That happened on `three_inscribed_bores_remesh_imprints_every_arc`:
468/// an arc spliced as a spur from a point of a bore's rim, and the rim arc
469/// spliced at that same point a moment later, landed the spur in the outer
470/// piece of the wall.
471///
472/// The curves are ordered around the vertex by where they first leave a
473/// small circle around it, not by their tangents: see [`Leg`].
474fn choose_corner(
475 model: &Model,
476 arriving: &[CoedgeId],
477 leaving: Leg<'_>,
478) -> GeopResult<Option<CoedgeId>> {
479 if arriving.len() <= 1 {
480 return Ok(arriving.first().copied());
481 }
482 let mut corners = Vec::new();
483 for &coedge_id in arriving {
484 let coedge = model.get_coedge(coedge_id)?;
485 let goes_to = Leg {
486 pcurve: &model.get_coedge(coedge.next)?.pcurve,
487 from_end: false,
488 };
489 let came_from = Leg {
490 pcurve: &coedge.pcurve,
491 from_end: true,
492 };
493 corners.push((coedge_id, goes_to, came_from));
494 }
495
496 // One radius for every leg, small enough that no leg meets another
497 // vertex inside it (see `Leg`).
498 let mut radius = leaving.reach()?;
499 for (_, goes_to, came_from) in &corners {
500 for leg in [goes_to, came_from] {
501 let reach = leg.reach()?;
502 if reach.definitely_less(radius) {
503 radius = reach;
504 }
505 }
506 }
507 let radius = radius.div(Scalar::TWO)?.sharpen();
508 if !radius.definitely_greater(Scalar::ZERO) {
509 return Err(GeopError::new(format!(
510 "splice_edge_into_face: a curve at the vertex returns to it at once, so no circle around the vertex orders the corners after coedges {arriving:?}"
511 )));
512 }
513
514 let d = leaving.exit(radius)?;
515 let mut chosen = Vec::new();
516 for (coedge_id, goes_to, came_from) in &corners {
517 let (from, to) = (goes_to.exit(radius)?, came_from.exit(radius)?);
518 match within_corner(from, to, d) {
519 Some(true) => chosen.push(*coedge_id),
520 Some(false) => {}
521 None => {
522 return Err(GeopError::new(format!(
523 "splice_edge_into_face: cannot tell whether an edge leaving towards {d:?} runs into the corner after coedge {coedge_id}, from {from:?} counter-clockwise to {to:?} (at radius {radius:?})"
524 )));
525 }
526 }
527 }
528 match chosen[..] {
529 [coedge_id] => Ok(Some(coedge_id)),
530 _ => Err(GeopError::new(format!(
531 "splice_edge_into_face: an edge leaving towards {d:?} runs into {} of the corners after coedges {arriving:?}, where it must run into exactly one",
532 chosen.len()
533 ))),
534 }
535}
536
537/// A pcurve run away from the vertex at one of its ends.
538///
539/// Several curves leaving one vertex are ordered around it by where each
540/// first leaves a circle of some radius around the vertex. Within a radius
541/// that no curve meets another vertex inside, their pieces in the disk are
542/// paths from its centre to its rim that do not cross — curves of a face's
543/// boundary, and an edge being spliced into it, meet only at vertices — and
544/// such paths leave the disk in the same cyclic order they leave its centre.
545/// So the order is exact at any such radius, and a larger one only separates
546/// the curves further.
547///
548/// Tangents would answer the same question only where the curves leave in
549/// different directions. Intersection curves of tangent configurations often
550/// leave a vertex tangent to each other, and then only curvature separates
551/// them — which, differentiated twice off a pcurve that encloses a traced
552/// curve, comes out too wide to decide anything.
553struct Leg<'a> {
554 pcurve: &'a Curve2,
555 /// Whether the vertex is at the pcurve's end (and it is run backwards).
556 from_end: bool,
557}
558
559impl Leg<'_> {
560 /// The parameters of the vertex end and of the pcurve's midpoint.
561 fn ends(&self) -> GeopResult<(Scalar, Scalar)> {
562 let (t0, t1) = self.pcurve.domain();
563 let mid = t0.add(t1).div(Scalar::TWO)?;
564 Ok((if self.from_end { t1 } else { t0 }, mid))
565 }
566
567 /// A radius this leg reaches, before meeting any vertex other than the
568 /// one it leaves: its distance to its midpoint, or to its far end where
569 /// that is nearer. A curve returning to the vertex it leaves has no far
570 /// end to meet.
571 fn reach(&self) -> GeopResult<Scalar> {
572 let (t0, t1) = self.pcurve.domain();
573 let (t_vertex, t_mid) = self.ends()?;
574 let t_far = if self.from_end { t0 } else { t1 };
575 let origin = self.pcurve.evaluate(t_vertex)?;
576 let mid = self.pcurve.evaluate(t_mid)?.sub(&origin).norm();
577 let far = self.pcurve.evaluate(t_far)?.sub(&origin).norm();
578 Ok(
579 if far.definitely_greater(Scalar::ZERO) && far.definitely_less(mid) {
580 far
581 } else {
582 mid
583 },
584 )
585 }
586
587 /// Where the leg leaves the circle of `radius` around its vertex,
588 /// relative to the vertex. Located by bisection between the vertex and
589 /// the midpoint, which lies outside a circle of at most half its reach;
590 /// the parameters bisected are the search's own choice, so they are sharp.
591 ///
592 /// Bisection finds *a* crossing of the circle, which is the first exit
593 /// the ordering argument needs as long as the leg does not leave the disk
594 /// and come back before its midpoint — true of the short, smooth trim
595 /// curves at half their reach, and an assumption for anything else.
596 fn exit(&self, radius: Scalar) -> GeopResult<Vector2> {
597 let (t_vertex, t_mid) = self.ends()?;
598 let origin = self.pcurve.evaluate(t_vertex)?;
599 let radius_sq = radius.mul(radius);
600 let mut point = self.pcurve.evaluate(t_mid)?.sub(&origin);
601 bisect(
602 t_vertex,
603 t_mid,
604 EXIT_BISECTIONS,
605 false,
606 |t| -> GeopResult<_> {
607 point = self.pcurve.evaluate(t)?.sub(&origin);
608 // Inside the circle is the vertex's side.
609 Ok(point
610 .norm_sq()
611 .sub(radius_sq)
612 .definite_sign()
613 .map(|out| !out))
614 },
615 )?;
616 Ok(point)
617 }
618}
619
620/// Bisection steps for [`Leg::exit`]. Bounds effort only: each step halves
621/// the stretch of curve the exit point is known to lie on, and the point
622/// returned is on the curve either way.
623const EXIT_BISECTIONS: usize = 60;
624
625/// Whether `d` lies strictly inside the angle swept counter-clockwise from
626/// `from` to `to`; `None` where the intervals cannot decide it. `from` and
627/// `to` pointing the same way sweep the full turn — the tip of a spur, whose
628/// corner is everything around it.
629fn within_corner(from: Vector2, to: Vector2, d: Vector2) -> Option<bool> {
630 let sign = |x: Scalar| {
631 if x.definitely_greater(Scalar::ZERO) {
632 Some(true)
633 } else if x.definitely_less(Scalar::ZERO) {
634 Some(false)
635 } else {
636 None
637 }
638 };
639 let after_from = sign(from.prod_cross(&d));
640 let before_to = sign(d.prod_cross(&to));
641 let turn = from.prod_cross(&to);
642 if turn.definitely_greater(Scalar::ZERO) {
643 // Less than half a turn: inside means past `from` and short of `to`.
644 match (after_from, before_to) {
645 (Some(false), _) | (_, Some(false)) => Some(false),
646 (Some(true), Some(true)) => Some(true),
647 _ => None,
648 }
649 } else if turn.definitely_less(Scalar::ZERO) {
650 // More than half a turn: everything but the complementary angle.
651 match (after_from, before_to) {
652 (Some(true), _) | (_, Some(true)) => Some(true),
653 (Some(false), Some(false)) => Some(false),
654 _ => None,
655 }
656 } else if from.prod_dot(&to).definitely_greater(Scalar::ZERO) {
657 // The full turn, unless `d` leaves along it.
658 after_from.map(|_| true)
659 } else {
660 None
661 }
662}
663
664/// Make `to` follow `from` in loop order, keeping `prev` consistent.
665fn link(model: &mut Model, from: CoedgeId, to: CoedgeId) -> GeopResult<()> {
666 model.get_coedge_mut(from)?.next = to;
667 model.get_coedge_mut(to)?.prev = from;
668 Ok(())
669}
670
671/// Insert `out`/`back` (an out-and-return pair on the same edge) into the
672/// loop right after `after`, leaving the rest of the loop untouched.
673fn splice_spur(
674 model: &mut Model,
675 after: CoedgeId,
676 out: CoedgeId,
677 back: CoedgeId,
678) -> GeopResult<()> {
679 let after_next = model.get_coedge(after)?.next;
680 link(model, after, out)?;
681 link(model, out, back)?;
682 link(model, back, after_next)?;
683 Ok(())
684}
685
686/// Marks the error raised when a splice would divide a face into a piece of
687/// no area. Callers that are *imprinting* an edge they already know follows
688/// this face's boundary can treat it as "already represented" rather than a
689/// failure — see `booleans::remesh`'s tracing. Matched on the message because
690/// `GeopError` carries no code; that is fragile enough to be worth the
691/// constant rather than a literal at both ends.
692pub const DEGENERATE_SPLIT: &str = "degenerate split";
693
694/// Cut `face_id` in two along the newly inserted edge: `keep` and `moved`
695/// anchor the two rings the old outer loop just split into.
696///
697/// `keep`'s ring stays with `face_id`; `moved`'s ring becomes the outer loop
698/// of a brand-new face, and the holes are re-sorted between the two
699/// ([`new_face_from_ring`]). Both rings bound material — that is what
700/// distinguishes this from every other case in `splice_edge_into_face` — so
701/// neither can become a hole of the other.
702fn split_face(
703 model: &mut Model,
704 face_id: FaceId,
705 keep: CoedgeId,
706 moved: CoedgeId,
707 max_nodes: usize,
708 min_subdivision_size: Scalar,
709) -> GeopResult<FaceId> {
710 model
711 .get_face_mut(face_id)?
712 .set_boundary(BoundaryIndex::Outer, BoundaryType::Loop(keep));
713 new_face_from_ring(model, face_id, moved, max_nodes, min_subdivision_size)
714}
715
716/// Split `ring` off `face_id` as the outer loop of a brand-new face on the
717/// same surface and in the same shell.
718///
719/// `face_id`'s own holes are then re-sorted, since each now lies inside
720/// exactly one of the two faces and the split has no idea which. Each is
721/// classified by taking a point on it and asking `face_contains` — a hole the
722/// new face contains moves there, everything else stays. A hole with no
723/// usable point (a bare `Vertex` boundary, or a loop whose `(u, v)` cannot be
724/// read) stays put rather than being dropped: leaving it on the wrong face is
725/// a recoverable error, losing it silently fills in a hole that should exist.
726fn new_face_from_ring(
727 model: &mut Model,
728 face_id: FaceId,
729 ring: CoedgeId,
730 max_nodes: usize,
731 min_subdivision_size: Scalar,
732) -> GeopResult<FaceId> {
733 let old = model.get_face(face_id)?.clone();
734 let new_face_id = model.insert_face(Face {
735 surface: old.surface.clone(),
736 outer: BoundaryType::Loop(ring),
737 holes: Vec::new(),
738 shell: old.shell,
739 });
740 model.get_shell_mut(old.shell)?.faces.push(new_face_id);
741
742 // Every coedge of the moved ring now belongs to the new face. Walked with
743 // a hard cap: a ring that never returns to its anchor would otherwise spin
744 // here forever, and a corrupted `next` chain is exactly the kind of thing
745 // a restructuring like this can introduce.
746 for c in ring_coedges(model, ring)? {
747 model.get_coedge_mut(c)?.face = new_face_id;
748 }
749
750 let mut stay = Vec::new();
751 let mut move_over = Vec::new();
752 for hole in old.holes {
753 let Some(uv) = boundary_uv(model, hole)? else {
754 stay.push(hole);
755 continue;
756 };
757 let inside_new = matches!(
758 face_contains(
759 model,
760 new_face_id,
761 uv[0],
762 uv[1],
763 max_nodes,
764 min_subdivision_size,
765 HOLE_CLASSIFY_SEED,
766 )?,
767 PointClassification::Inside
768 );
769 if inside_new {
770 move_over.push(hole);
771 } else {
772 stay.push(hole);
773 }
774 }
775 for hole in &move_over {
776 if let BoundaryType::Loop(anchor) = hole {
777 for c in ring_coedges(model, *anchor)? {
778 model.get_coedge_mut(c)?.face = new_face_id;
779 }
780 }
781 }
782 model.get_face_mut(face_id)?.holes = stay;
783 model.get_face_mut(new_face_id)?.holes = move_over;
784 Ok(new_face_id)
785}
786
787/// Which coedge of the ring anchored at `anchor` has the widest pcurve, at
788/// `WIDTH_SAMPLES` points along each, and how wide: what an area too
789/// uncertain to be told from zero comes from — a ring that really encloses
790/// nothing, or a pcurve carrying too much uncertainty to say. Only for the
791/// error message, never for a decision.
792fn widest_pcurve(model: &Model, anchor: CoedgeId) -> GeopResult<String> {
793 const WIDTH_SAMPLES: usize = 8;
794 let width =
795 |uv: &geop_core_math::vector::Vector2| uv[0].width().to_f64().max(uv[1].width().to_f64());
796 let mut widest = (0.0, None);
797 let mut each = Vec::new();
798 for coedge_id in ring_coedges(model, anchor)? {
799 let coedge = model.get_coedge(coedge_id)?;
800 let (t0, t1) = coedge.pcurve.domain();
801 let mut samples = Vec::with_capacity(WIDTH_SAMPLES);
802 for i in 0..WIDTH_SAMPLES {
803 let frac = Scalar::from_ratio(i as i64, (WIDTH_SAMPLES - 1) as i64)?;
804 let uv = coedge.pcurve.evaluate(t0.add(t1.sub(t0).mul(frac)))?;
805 if width(&uv) > widest.0 {
806 widest = (width(&uv), Some((coedge_id, coedge.geometry, uv)));
807 }
808 samples.push(width(&uv));
809 }
810 let mid = samples[samples.len() / 2];
811 each.push(format!(
812 "{:?}: {:.1e} / {mid:.1e} / {:.1e}",
813 coedge.geometry,
814 samples[0],
815 samples[samples.len() - 1]
816 ));
817 }
818 let each = each.join(", ");
819 Ok(match widest.1 {
820 Some((coedge, geometry, uv)) => {
821 // How fast the surface moves there: a slow parametrization
822 // turns a narrow point into a wide `(u, v)`.
823 let face = model.get_coedge(coedge)?.face;
824 let surface_width = model
825 .get_face(face)?
826 .surface
827 .control_points
828 .iter()
829 .flat_map(|cp| (0..4).map(move |k| cp[k].width().to_f64()))
830 .fold(0.0, f64::max);
831 let speed = model
832 .get_face(face)?
833 .surface
834 .derivatives(uv[0].midpoint(), uv[1].midpoint())
835 .map(|(du, dv)| format!("|dS/du| = {:?}, |dS/dv| = {:?}", du.norm(), dv.norm()))
836 .unwrap_or_else(|e| format!("no derivatives: {}", e.root_message()));
837 // Whether its ends land on the surface where its vertices are: a
838 // pcurve pinned to a `(u, v)` off its vertex is widened to span
839 // the gap all along.
840 let coedge_data = model.get_coedge(coedge)?;
841 let surface = &model.get_face(face)?.surface;
842 let (t0, t1) = coedge_data.pcurve.domain();
843 let mut ends = Vec::new();
844 for (t, vertex) in [
845 (t0, model.coedge_start_vertex(coedge)?),
846 (t1, model.coedge_end_vertex(coedge)?),
847 ] {
848 let uv = coedge_data.pcurve.evaluate(t)?;
849 let width = (0..3)
850 .map(|k| vertex.point[k].width().to_f64())
851 .fold(0.0, f64::max);
852 ends.push(match surface.evaluate(uv[0].midpoint(), uv[1].midpoint()) {
853 Ok(p) => format!(
854 "{:.1e} (vertex {width:.1e} wide)",
855 p.sub(&vertex.point).norm().to_f64()
856 ),
857 Err(_) => {
858 format!("(outside the surface's domain at {uv:?}; vertex {width:.1e} wide)")
859 }
860 });
861 }
862 let ends = ends.join(" and ");
863 // And whether it inherits that from its edge's 3-D curve.
864 let curve_width = match geometry {
865 CoedgeGeometry::Edge(edge) => model
866 .get_edge(edge)?
867 .curve
868 .control_points
869 .iter()
870 .flat_map(|cp| (0..4).map(move |k| cp[k].width().to_f64()))
871 .fold(0.0, f64::max),
872 CoedgeGeometry::Vertex(_) => 0.0,
873 };
874 format!(
875 "coedge {coedge} on {geometry:?}, {:e} wide at uv={uv:?}, where {speed} and the surface's control points are up to {surface_width:e} wide; its ends land {ends} away from their vertices; its 3-D curve's control points up to {curve_width:e} wide. Every coedge's width at its start / middle / end: {each}",
876 widest.0
877 )
878 }
879 None => "every sample sharp".into(),
880 })
881}
882
883/// Every coedge of the ring anchored at `anchor`, erroring rather than
884/// spinning if the `next` chain never returns to it.
885fn ring_coedges(model: &Model, anchor: CoedgeId) -> GeopResult<Vec<CoedgeId>> {
886 let cap = model.coedges.len() + 1;
887 let ring: Vec<CoedgeId> = model.iterate_loop_coedges(anchor).take(cap).collect();
888 if ring.len() >= cap {
889 return Err(GeopError::new(format!(
890 "splice_edge_into_face: the ring anchored at coedge {anchor} never returns to its anchor"
891 )));
892 }
893 Ok(ring)
894}
895
896/// Fixed seed for the ray casting behind hole classification — `face_contains`
897/// retries until it finds a ray grazing nothing, so the answer is
898/// seed-independent and a constant keeps a split reproducible run to run.
899const HOLE_CLASSIFY_SEED: u64 = 0x1234_5678_9ABC_DEF0;
900
901/// A `(u, v)` on `boundary`, for classifying which side of a split it falls
902/// on. `None` for a bare `Vertex` boundary, which has no pcurve to read one
903/// from.
904fn boundary_uv(
905 model: &Model,
906 boundary: BoundaryType,
907) -> GeopResult<Option<geop_core_math::vector::Vector2>> {
908 let BoundaryType::Loop(anchor) = boundary else {
909 return Ok(None);
910 };
911 let pcurve = &model.get_coedge(anchor)?.pcurve;
912 let (t0, _) = pcurve.domain();
913 Ok(Some(pcurve.evaluate(t0)?))
914}