ogeom_mesh/triangulate.rs
1//! Triangulating a face.
2//!
3//! The second half of tessellation. A face is a trimmed region of a surface, so
4//! the triangulation is built in the surface's `(u, v)` parameter space (where
5//! the region is an ordinary polygon with holes) and then lifted back into
6//! space by evaluating the surface at each vertex.
7//!
8//! # Why parameter space
9//!
10//! Triangulating in 3D would mean deciding which side of a curved boundary a
11//! point falls on, in space, which is the point-in-solid problem. In parameter
12//! space the boundary is a closed 2D polygon and the question is a winding
13//! count. The surface does the rest.
14//!
15//! The cost is that parameter space is distorted: equal steps in `(u, v)` cover
16//! very different distances near a sphere's pole than near its equator. So the
17//! interior points are chosen by measuring deflection *in space* and the
18//! triangulation is done in parameter space: measuring where the answer
19//! matters, connecting where it is easy.
20//!
21//! # Watertightness
22//!
23//! A face's boundary points come from discretizing the *edge's* 3D curve and
24//! evaluating the pcurve at those same parameters. Two faces sharing an edge
25//! therefore place their boundary vertices at identical spatial positions, and
26//! the join has no gap. Discretizing each face's pcurve independently would
27//! give each face its own idea of where the edge runs, and the seams would show.
28
29use ogeom_core::{Exact, OgeomResult, Predicates, Tolerances, ogeom_bail};
30use ogeom_geom::Curve3d as _;
31use ogeom_geom::{Curve2d, Surface, SurfaceGeometry};
32use ogeom_math::{Direction, Point, Point2, Vector};
33use ogeom_topo::{EdgeRepr, Model, NodeData, Orientation, Shape, ShapeType, Triangulation};
34use spade::{
35 ConstrainedDelaunayTriangulation, Point2 as SpadePoint, Triangulation as _, mitigate_underflow,
36};
37
38use crate::discretize::{Deflection, discretize};
39
40/// Triangulate one face.
41///
42/// # Errors
43///
44/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `face` is not a
45/// face or its geometry is missing;
46/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle fails to
47/// resolve; [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the boundary
48/// cannot be triangulated.
49pub fn triangulate_face(
50 model: &Model,
51 face: &Shape,
52 deflection: Deflection,
53 tol: Tolerances,
54) -> OgeomResult<Triangulation> {
55 // One pass at the caller's chord, and a second only where the first
56 // came back short, the same shape as the whole-shape path, so a caller
57 // meshing face by face pays for one triangulation per face, not two.
58 let nothing = EdgeChords::new();
59 let (mesh, verdict) = triangulate_reporting(model, face, deflection, Some(¬hing), tol)?;
60 if verdict != Verdict::Short {
61 return Ok(mesh);
62 }
63 triangulate_with(model, face, deflection, None, tol)
64}
65
66/// What one pass over a face found.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68enum Verdict {
69 /// The boundary enclosed a region and it was drawn.
70 Whole,
71 /// The boundary crossed itself: what came back is fragments, or nothing.
72 /// The edges want drawing finer.
73 Short,
74}
75
76/// One face, with the finer edge chords the whole shape agreed on.
77///
78/// `None` means this face is being meshed on its own and may work out its
79/// own: alone it has no neighbour to disagree with.
80fn triangulate_with(
81 model: &Model,
82 face: &Shape,
83 deflection: Deflection,
84 finer: Option<&EdgeChords>,
85 tol: Tolerances,
86) -> OgeomResult<Triangulation> {
87 let (mesh, verdict) = triangulate_reporting(model, face, deflection, finer, tol)?;
88 if verdict == Verdict::Short && mesh.triangles.is_empty() {
89 ogeom_bail!(
90 NotDone,
91 "the face's boundary enclosed no triangulable region"
92 );
93 }
94 Ok(mesh)
95}
96
97/// The surface's unit normal at `(u, v)`, or where the surface is
98/// degenerate there (a cone's apex, a sphere's pole, a patch's collapsed
99/// corner), the normal a step inside along the vertex's own column.
100///
101/// A degenerate point has no normal of its own, and `None` for it left
102/// the vertex shading black and dragged every normal it was welded with
103/// towards nothing. It has a *limit* normal along any line approaching
104/// it: the apex of a cone seen up one ruling is that ruling's normal, and
105/// a mesh vertex at the apex carries the `u` of the ruling it closes. The
106/// step is a millionth of the domain towards its middle, first along
107/// `v`, then `u`, then both; `None` only where all three are degenerate.
108fn limit_normal(surface: &SurfaceGeometry, u: f64, v: f64, tol: Tolerances) -> Option<Vector> {
109 if let Ok(n) = surface.normal_at(u, v, tol) {
110 return Some(n.vector());
111 }
112 let ((ua, ub), (va, vb)) = surface.domain();
113 let su = if u < f64::midpoint(ua, ub) { 1.0 } else { -1.0 };
114 let sv = if v < f64::midpoint(va, vb) { 1.0 } else { -1.0 };
115 // Widening steps: a patch whose corner collapses with its tangent
116 // (the control rows drawn together and the next row too) is degenerate
117 // to first order for a stretch, and a millionth of the domain is still
118 // inside it; on a sphere a millimetre across the tangents a millionth
119 // in from the pole cross to less than a direction resolves. A
120 // hundredth of the domain is past both, and on a patch that small the
121 // normal there is the corner's for every purpose.
122 for scale in [1e-6, 1e-4, 1e-2] {
123 let du = (ub - ua).abs().max(f64::EPSILON) * scale * su;
124 let dv = (vb - va).abs().max(f64::EPSILON) * scale * sv;
125 let found = [(u, v + dv), (u + du, v), (u + du, v + dv)]
126 .into_iter()
127 .find_map(|(nu, nv)| surface.normal_at(nu, nv, tol).ok().map(|n| n.vector()));
128 if found.is_some() {
129 return found;
130 }
131 }
132 None
133}
134
135/// One face, and whether the rings it was built from crossed themselves.
136///
137/// The flag is how the shape-wide pass learns which faces need their edges
138/// drawn finer without building every face's rings twice: the rings are
139/// already in hand here, and the sweep over them is the only extra cost a
140/// face that does not cross ever pays.
141fn triangulate_reporting(
142 model: &Model,
143 face: &Shape,
144 deflection: Deflection,
145 finer: Option<&EdgeChords>,
146 tol: Tolerances,
147) -> OgeomResult<(Triangulation, Verdict)> {
148 triangulate_reporting_from(model, face, deflection, finer, None, tol)
149}
150
151/// A face's rings walked ahead of drawing, and the chord its edges want
152/// if it is narrower than a few of the caller's.
153type Walked = (Trimming, Option<f64>);
154
155/// [`triangulate_reporting`] with the rings already walked, where the
156/// caller has them and none of the face's edges were told to draw finer
157/// since; the rings depend on nothing else.
158fn triangulate_reporting_from(
159 model: &Model,
160 face: &Shape,
161 deflection: Deflection,
162 finer: Option<&EdgeChords>,
163 prepared: Option<Trimming>,
164 tol: Tolerances,
165) -> OgeomResult<(Triangulation, Verdict)> {
166 deflection.validate()?;
167 if model.kind_of(face)? != ShapeType::Face {
168 ogeom_bail!(Construction, "expected a face");
169 }
170 let Some(node) = model.node(face) else {
171 ogeom_bail!(Dangling, "face is not in this model");
172 };
173 let NodeData::Face(data) = node.data() else {
174 ogeom_bail!(Construction, "face node holds no face data");
175 };
176 let Some(surface) = model.geometry().surface(data.surface) else {
177 ogeom_bail!(Dangling, "face refers to a surface not in this model");
178 };
179 let placement = face.transform(model.datums())?;
180
181 let own;
182 let finer = match finer {
183 Some(shared) => shared,
184 None => {
185 own = face_chords(model, face, data.surface, surface, deflection, tol)?;
186 &own
187 }
188 };
189 let phase = std::time::Instant::now();
190 let Trimming {
191 rings: uv,
192 anchors,
193 met,
194 walked,
195 } = match prepared {
196 Some(trim) => trim,
197 None => trimming_rings(model, face, data.surface, surface, deflection, finer, tol)?,
198 };
199 let rings_ms = phase.elapsed().as_secs_f64() * 1e3;
200 if uv.is_empty() {
201 // Bounded, and bounding next to nothing: a sliver whose sides are
202 // one line in the chart. It is drawn as a fan across each ring its
203 // edges walked, at the points its neighbours share along those
204 // edges, so the whole mesh still closes over it.
205 return Ok((sliver_fan(&walked, surface, tol), Verdict::Whole));
206 }
207 let phase = std::time::Instant::now();
208 let planar = triangulate_region(&uv, surface, deflection, tol)?;
209 let region_ms = phase.elapsed().as_secs_f64() * 1e3;
210 let phase = std::time::Instant::now();
211
212 // Whether the triangulator was handed a region at all, asked of what it
213 // returned rather than of what it was given. A well-formed triangulation
214 // over `b` boundary points in `w` rings has at least `b + 2w - 4`
215 // triangles: exactly that where no interior point is added, more where
216 // refinement adds them. Fewer is not a coarse answer, it is a different
217 // shape: the boundary crossed itself and what came back is disconnected
218 // fragments with holes between them.
219 //
220 // Counting is why it is asked this way round. Sweeping the rings for a
221 // crossing is the direct question and costs a sort and an active list
222 // per face; measured over a hundred thousand faces it was the whole of
223 // an eighteen per cent regression, to catch four bodies. The count is
224 // already in hand and exact for the failure that matters.
225 //
226 // That count is exact only before interior points go in; over a face
227 // that takes hundreds of them, a crossing that costs a handful of
228 // triangles is lost in the total. So the region also counts its own
229 // triangles the moment the boundary is in and nothing else, where the
230 // number is exactly `b + 2w - 4` for a boundary that encloses a region
231 // and anything else for one that crosses.
232 //
233 // And a face narrower than a few chords is short too, whatever its
234 // count: drawn at the caller's chord its boundary sags by more than
235 // the face is wide, and every triangle across the width stands off the
236 // surface by that sag. Its edges want a chord under the width, which
237 // is [`face_chords`]' first move; it is asked here so the whole-shape
238 // pass tells the neighbours to draw those edges finer too.
239 let boundary: usize = uv.iter().map(Vec::len).sum();
240 let narrow = narrow_chord(surface, &uv, deflection, tol)
241 .is_some_and(|want| !edges_already_at(model, face, finer, want));
242 let crossed = narrow || planar.crossed || planar.triangles.len() + 4 < boundary + 2 * uv.len();
243 if *MESH_DEBUG_REFINE {
244 eprintln!(
245 "SHORT {} triangles against {boundary} boundary points in {} rings: crossed {crossed} (boundary pass {})",
246 planar.triangles.len(),
247 uv.len(),
248 planar.crossed
249 );
250 }
251
252 // Boundary vertices take their positions from their edges' own curves
253 // (the shared authority), keyed by their exact parameter-space bits.
254 let mut anchored: std::collections::HashMap<(u64, u64), Point> =
255 std::collections::HashMap::new();
256 for (ring, ring_anchor) in uv.iter().zip(&anchors) {
257 for (p, a) in ring.iter().zip(ring_anchor) {
258 if let Some(point) = a {
259 anchored.insert((p.x.to_bits(), p.y.to_bits()), *point);
260 }
261 }
262 }
263
264 // Lift into space. The normal follows the face's orientation, not the
265 // surface's: a reversed face presents the other side, and a renderer or a
266 // volume computation that ignored that would have the solid inside out.
267 // A reflecting placement flips it once more: the mirrored chart's
268 // natural normal points the other way through the same flag.
269 let flip = (face.orientation() == Orientation::Reversed)
270 != !face.location().preserves_handedness(model.datums())?;
271 let mut mesh = Triangulation::new();
272 mesh.deflection_met = met;
273 let mut hits = 0_usize;
274 for (u, v) in planar.parameters {
275 // Anchors are already in world coordinates (their edges' own
276 // placements applied), where surface lifts still need the face's.
277 let point = match anchored.get(&(u.to_bits(), v.to_bits())) {
278 Some(anchor) => {
279 hits += 1;
280 *anchor
281 }
282 None => placement.apply(surface.point_at(u, v, tol)?),
283 };
284 let normal =
285 limit_normal(surface, u, v, tol).map_or(Vector::ZERO, |n| placement.apply_vector(n));
286 mesh.positions.push(point);
287 mesh.normals.push(if flip { -normal } else { normal });
288 mesh.parameters.push((u, v));
289 }
290 if *MESH_DEBUG_REFINE && (rings_ms + region_ms) > 50.0 {
291 eprintln!(
292 "PHASE rings {rings_ms:.0}ms region {region_ms:.0}ms lift {:.0}ms ({} ring points, {} tris)",
293 phase.elapsed().as_secs_f64() * 1e3,
294 uv.iter().map(Vec::len).sum::<usize>(),
295 planar.triangles.len()
296 );
297 }
298 if *MESH_DEBUG {
299 eprintln!(
300 "DBG anchors map={} hits={} verts={}",
301 anchored.len(),
302 hits,
303 mesh.positions.len()
304 );
305 }
306 mesh.triangles = planar
307 .triangles
308 .into_iter()
309 .map(|t| if flip { [t[0], t[2], t[1]] } else { t })
310 .collect();
311 Ok((
312 mesh,
313 if crossed {
314 Verdict::Short
315 } else {
316 Verdict::Whole
317 },
318 ))
319}
320
321/// Triangulate every face below a shape, welded into one mesh.
322///
323/// # Errors
324///
325/// As [`triangulate_face`].
326pub fn triangulate(
327 model: &Model,
328 shape: &Shape,
329 deflection: Deflection,
330 tol: Tolerances,
331) -> OgeomResult<Triangulation> {
332 // Faces in two phases, as `tessellate` does: each face is meshed from a
333 // model nothing is writing to, in parallel; the pieces are then appended
334 // sequentially in face order. The split is what keeps the answer
335 // bit-identical at any thread count: scheduling decides only who does
336 // which face, never where its triangles land.
337 let faces: Vec<Shape> =
338 ogeom_topo::explore(model, shape, ogeom_topo::Filter::OfType(ShapeType::Face))?;
339 let read_model: &Model = model;
340
341 let (computed, _) = face_meshes(read_model, &faces, deflection, tol)?;
342
343 let mut mesh = Triangulation::new();
344 let mut pieces: Vec<(usize, usize)> = Vec::with_capacity(faces.len());
345 for piece in computed {
346 let piece = piece?;
347 let t0 = mesh.triangles.len();
348 mesh.append(&piece);
349 pieces.push((t0, mesh.triangles.len()));
350 }
351 orient_pieces(&mut mesh, &pieces);
352 let mesh = mesh.welded(tol);
353
354 // A second, border-only pass at the tolerance the model itself recorded:
355 // imported edges carry the file's slop in their widened tolerances, and
356 // two faces lifting the same edge through disagreeing geometry land that
357 // far apart. Interior edges are already manifold and are not touched.
358 let mut reach = 0.0_f64;
359 for kind in [ShapeType::Edge, ShapeType::Vertex] {
360 for shape in ogeom_topo::explore(model, shape, ogeom_topo::Filter::OfType(kind))? {
361 let recorded = model.node(&shape).map_or(0.0, |n| match n.data() {
362 NodeData::Edge(d) => d.tolerance.get(),
363 NodeData::Vertex(d) => d.tolerance.get(),
364 _ => 0.0,
365 });
366 reach = reach.max(recorded);
367 }
368 }
369 // Floored at a tenth of the chord the caller asked for: a border gap
370 // smaller than that is below the resolution of the mesh they accepted,
371 // whether or not the model recorded the slop that caused it.
372 let reach = reach.max(deflection.chord * 0.1);
373 let mesh = if reach > tol.confusion() {
374 let reach = reach + tol.confusion();
375 mesh.border_welded(reach).border_stitched(reach)
376 } else {
377 mesh
378 };
379 // What is left open narrower than the chord asked for is two faces
380 // sampling a shared corner differently, below the mesh's own
381 // resolution, and is sealed; a wider opening is the shape's.
382 Ok(mesh.sealed(deflection.chord))
383}
384
385/// Every face below a shape drawn to the chords the faces agree on, in face
386/// order, and those chords.
387///
388/// The first pass draws each face at the caller's chord on top of the
389/// narrow faces' asks; a face whose boundary crossed itself folds finer
390/// chords into the agreement, and only the faces touching an edge whose
391/// chord changed are drawn again. The whole-shape mesh welds these, and the
392/// stored tessellation keeps them face by face: the same meshes, drawn
393/// once.
394pub(crate) fn face_meshes(
395 read_model: &Model,
396 faces: &[Shape],
397 deflection: Deflection,
398 tol: Tolerances,
399) -> OgeomResult<(Vec<OgeomResult<Triangulation>>, EdgeChords)> {
400 let FirstPass {
401 finer,
402 mut computed,
403 changed,
404 } = first_pass(read_model, faces, deflection, tol)?;
405 if !changed.is_empty() {
406 let again: Vec<usize> = (0..faces.len())
407 .filter(|&i| {
408 ogeom_topo::explore(
409 read_model,
410 &faces[i],
411 ogeom_topo::Filter::OfType(ShapeType::Edge),
412 )
413 .is_ok_and(|es| es.iter().any(|e| changed.contains(&e.node().index())))
414 })
415 .collect();
416 let redone: Vec<OgeomResult<Triangulation>> =
417 ogeom_core::parallel::map_ordered(&again, |_, &index| {
418 ogeom_core::progress::checkpoint()?;
419 triangulate_with(read_model, &faces[index], deflection, Some(&finer), tol)
420 });
421 for (index, one) in again.into_iter().zip(redone) {
422 computed[index] = one;
423 }
424 }
425 Ok((computed, finer))
426}
427
428/// What the first pass over a shape's faces produced: the chords its faces
429/// agreed to draw their shared edges to, the meshes drawn at the caller's
430/// chord, and the edges whose chord the crossed faces changed after those
431/// meshes were drawn; the faces touching one of them are drawn again.
432struct FirstPass {
433 finer: EdgeChords,
434 computed: Vec<OgeomResult<Triangulation>>,
435 changed: std::collections::HashSet<u32>,
436}
437
438/// Every face drawn once at the caller's deflection, each saying whether
439/// the rings it was drawn from crossed themselves, on top of the chords
440/// the narrow faces asked for. Nearly none cross, and those faces are
441/// finished. A face whose boundary crossed itself needs its edges drawn
442/// finer, and so does every face that shares one of them, or the two
443/// sides of that edge arrive with a different number of points, which is
444/// a worse crack than the sliver the refinement was for. Their chords
445/// are folded into the map and the edges that changed are named.
446fn first_pass(
447 read_model: &Model,
448 faces: &[Shape],
449 deflection: Deflection,
450 tol: Tolerances,
451) -> OgeomResult<FirstPass> {
452 // `Some(&finer)`, never `None`: a face left to itself refines its own
453 // edges, which is right when it is meshed alone and wrong here, where
454 // its neighbours must be told to refine the same ones. Every face is
455 // drawn at exactly what the caller asked and reports what crossed.
456 //
457 // Before that, every face's rings are walked at the caller's chord and
458 // its width read off them: a face narrower than a few chords wants its
459 // edges drawn finer, and so do the faces across those edges. Known
460 // before anything is drawn, those chords go into this pass, and the
461 // big faces round a thousand small fillets are drawn once rather than
462 // once and again. The rings are kept for the faces they still
463 // describe: every face none of whose edges the map names.
464 let (mut finer, kept) = walk_for_chords(read_model, faces, deflection, tol);
465 let touched = |face: &Shape| -> bool {
466 ogeom_topo::explore(
467 read_model,
468 face,
469 ogeom_topo::Filter::OfType(ShapeType::Edge),
470 )
471 .is_ok_and(|es| es.iter().any(|e| finer.contains_key(&e.node().index())))
472 };
473 // The rings are handed over by the job that draws the face; a shared
474 // slice cannot give them away, so each sits behind a lock it is taken
475 // from once.
476 let jobs: Vec<(&Shape, std::sync::Mutex<Option<Trimming>>)> = faces
477 .iter()
478 .zip(kept)
479 .map(|(face, prep)| {
480 let trim = prep.filter(|_| !touched(face));
481 (face, std::sync::Mutex::new(trim))
482 })
483 .collect();
484 let first: Vec<OgeomResult<(Triangulation, Verdict)>> =
485 ogeom_core::parallel::map_ordered(&jobs, |_, (face, slot)| {
486 ogeom_core::progress::checkpoint()?;
487 let trim = slot.lock().ok().and_then(|mut held| held.take());
488 triangulate_reporting_from(read_model, face, deflection, Some(&finer), trim, tol)
489 });
490 let mut computed: Vec<OgeomResult<Triangulation>> = Vec::with_capacity(faces.len());
491 let mut crossed: Vec<usize> = Vec::new();
492 for (index, one) in first.into_iter().enumerate() {
493 match one {
494 Ok((mesh, Verdict::Short)) => {
495 crossed.push(index);
496 computed.push(Ok(mesh));
497 }
498 Ok((mesh, Verdict::Whole)) => computed.push(Ok(mesh)),
499 Err(e) => computed.push(Err(e)),
500 }
501 }
502 if *MESH_DEBUG_REFINE && !crossed.is_empty() {
503 eprintln!(
504 "REFINE {} of {} faces came up short",
505 crossed.len(),
506 faces.len()
507 );
508 }
509 // On top of the walk's map, not instead of it: a neighbour drawn again
510 // must still draw the edges the walk held finer at that chord, or the
511 // two sides of one of them disagree. Only an edge whose chord
512 // *changed* sends its faces back.
513 let mut changed: std::collections::HashSet<u32> = std::collections::HashSet::new();
514 for &index in &crossed {
515 let face = &faces[index];
516 let Some(node) = read_model.node(face) else {
517 continue;
518 };
519 let NodeData::Face(data) = node.data() else {
520 continue;
521 };
522 let Some(surface) = read_model.geometry().surface(data.surface) else {
523 continue;
524 };
525 for (edge, chord) in face_chords(read_model, face, data.surface, surface, deflection, tol)?
526 {
527 let held = finer.entry(edge).or_insert(f64::INFINITY);
528 if chord < *held {
529 *held = chord;
530 changed.insert(edge);
531 }
532 }
533 }
534 Ok(FirstPass {
535 finer,
536 computed,
537 changed,
538 })
539}
540
541/// Every face's rings walked at the caller's chord, and the chords the
542/// narrow faces want their edges drawn to, agreed across the shape: the
543/// finest any face asks of an edge is what every face draws it to.
544///
545/// The rings are kept for the faces they still describe (every face none
546/// of whose edges the map names), so the whole-shape pass draws those once.
547fn walk_for_chords(
548 read_model: &Model,
549 faces: &[Shape],
550 deflection: Deflection,
551 tol: Tolerances,
552) -> (EdgeChords, Vec<Option<Trimming>>) {
553 let nothing = EdgeChords::new();
554 let prepared: Vec<OgeomResult<Option<Walked>>> =
555 ogeom_core::parallel::map_ordered(faces, |_, face| {
556 ogeom_core::progress::checkpoint()?;
557 let Some(node) = read_model.node(face) else {
558 return Ok(None);
559 };
560 let NodeData::Face(data) = node.data() else {
561 return Ok(None);
562 };
563 let Some(surface) = read_model.geometry().surface(data.surface) else {
564 return Ok(None);
565 };
566 let trim = trimming_rings(
567 read_model,
568 face,
569 data.surface,
570 surface,
571 deflection,
572 ¬hing,
573 tol,
574 )?;
575 let narrow = narrow_chord(surface, &trim.rings, deflection, tol);
576 Ok(Some((trim, narrow)))
577 });
578 let mut finer = EdgeChords::new();
579 let mut kept: Vec<Option<Trimming>> = Vec::with_capacity(faces.len());
580 for (face, one) in faces.iter().zip(prepared) {
581 match one {
582 Ok(Some((prep, narrow))) => {
583 if let Some(chord) = narrow
584 && let Ok(edges) = ogeom_topo::explore(
585 read_model,
586 face,
587 ogeom_topo::Filter::OfType(ShapeType::Edge),
588 )
589 {
590 for edge in edges {
591 let held = finer.entry(edge.node().index()).or_insert(chord);
592 *held = held.min(chord);
593 }
594 }
595 kept.push(Some(prep));
596 }
597 Ok(None) | Err(_) => kept.push(None),
598 }
599 }
600 (finer, kept)
601}
602
603/// The chords the faces below `shape` agree to draw their shared edges to.
604///
605/// A face narrower than a few chords draws its edges finer than the
606/// caller asked, and so does a face whose boundary crosses itself at that
607/// chord; the face across each of those edges must draw it the same or
608/// the two meshes disagree along it: a crack in every mesh assembled
609/// face by face. [`triangulate`] agrees this for itself; a caller meshing
610/// face by face (a viewer keeping one mesh per face) asks here once per
611/// shape and hands the answer to [`triangulate_face_with`] for every face.
612///
613/// The answer costs a draw of every face at the caller's chord, since a
614/// boundary is only known to cross itself once it is triangulated; a
615/// caller meshing face by face pays that draw twice over, and one that
616/// wants only the welded whole should ask [`triangulate`] instead.
617///
618/// # Errors
619///
620/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
621/// deflection is unusable; a face that will not draw asks for nothing
622/// rather than failing the shape.
623pub fn edge_chords_for(
624 model: &Model,
625 shape: &Shape,
626 deflection: Deflection,
627 tol: Tolerances,
628) -> OgeomResult<EdgeChords> {
629 deflection.validate()?;
630 let faces: Vec<Shape> =
631 ogeom_topo::explore(model, shape, ogeom_topo::Filter::OfType(ShapeType::Face))?;
632 Ok(first_pass(model, &faces, deflection, tol)?.finer)
633}
634
635/// As [`triangulate_face`], with the edge chords the shape agreed on (the
636/// answer of [`edge_chords_for`]), so the face's boundary matches its
637/// neighbours' point for point along every edge the map names.
638///
639/// The agreement already holds every refinement the face's own boundary
640/// asked for, so the face is drawn to it and left there, as the
641/// whole-shape triangulation leaves it: refining alone past the agreed
642/// chord, as [`triangulate_face`] would, is what the neighbours cannot
643/// follow.
644///
645/// # Errors
646///
647/// As [`triangulate_face`].
648pub fn triangulate_face_with(
649 model: &Model,
650 face: &Shape,
651 deflection: Deflection,
652 chords: &EdgeChords,
653 tol: Tolerances,
654) -> OgeomResult<Triangulation> {
655 triangulate_with(model, face, deflection, Some(chords), tol)
656}
657
658/// Make the appended face meshes traverse their shared boundaries in
659/// opposite directions, flipping as few faces as possible.
660///
661/// A closed oriented surface walks each interior edge once each way. A file
662/// whose face orientation flags disagree with each other still *pairs* every
663/// edge (closed by counting, two-sided nowhere), and every integral over the
664/// result is reference-dependent garbage. The face flags cannot be judged
665/// from the model's wires (a synthesised seam's occurrence directions are
666/// chart bookkeeping, not 3D traversal), but the meshes tell the truth:
667/// shared boundary vertices are anchored to their edges' own curves, so two
668/// faces meeting along an edge carry bitwise-identical positions, and the
669/// direction each walks them is right there in the triangles. Faces are
670/// flood-filled across those shared runs, each constrained to oppose its
671/// neighbour, and each connected component keeps the polarity that flips the
672/// minority; flipping means reversing the piece's windings and negating its
673/// normals. Conflicts are left standing: this repairs orientation, not
674/// topology.
675fn orient_pieces(mesh: &mut Triangulation, pieces: &[(usize, usize)]) {
676 use std::collections::HashMap;
677 type Key = (u64, u64, u64);
678 let key = |p: &Point| -> Key { (p.x.to_bits(), p.y.to_bits(), p.z.to_bits()) };
679
680 // Directed boundary edges per piece, keyed by position: only each
681 // piece's *border* edges (used once within the piece) face other pieces.
682 let mut owners: HashMap<(Key, Key), Vec<(usize, bool)>> = HashMap::new();
683 for (i, &(t0, t1)) in pieces.iter().enumerate() {
684 let mut inside: HashMap<(u32, u32), u32> = HashMap::new();
685 for t in &mesh.triangles[t0..t1] {
686 for k in 0..3 {
687 let (a, b) = (t[k], t[(k + 1) % 3]);
688 *inside.entry((a.min(b), a.max(b))).or_default() += 1;
689 }
690 }
691 for t in &mesh.triangles[t0..t1] {
692 for k in 0..3 {
693 let (a, b) = (t[k], t[(k + 1) % 3]);
694 if inside.get(&(a.min(b), a.max(b))).copied() != Some(1) {
695 continue;
696 }
697 let (ka, kb) = (
698 key(&mesh.positions[a as usize]),
699 key(&mesh.positions[b as usize]),
700 );
701 // One undirected key, direction recorded: `true` where this
702 // piece walks the smaller key first.
703 let (lo, hi, forward) = if ka <= kb {
704 (ka, kb, true)
705 } else {
706 (kb, ka, false)
707 };
708 owners.entry((lo, hi)).or_default().push((i, forward));
709 }
710 }
711 }
712
713 // Manifold constraints: exactly two pieces sharing a run. Same recorded
714 // direction means exactly one must flip. A pair of faces shares many
715 // runs and slop can corrupt a few, so each pair's constraint is the
716 // majority of its runs, and ties abstain rather than guess. Everything
717 // is aggregated in sorted order, so the answer does not depend on hash
718 // iteration.
719 let mut votes: std::collections::BTreeMap<(usize, usize), (u32, u32)> =
720 std::collections::BTreeMap::new();
721 for list in owners.values() {
722 if let [(a, fa), (b, fb)] = list[..]
723 && a != b
724 {
725 let pair = (a.min(b), a.max(b));
726 let entry = votes.entry(pair).or_insert((0, 0));
727 if fa == fb {
728 entry.0 += 1;
729 } else {
730 entry.1 += 1;
731 }
732 }
733 }
734 // Strongest constraints first: a pair vouched for by many runs outranks
735 // one hanging on a sliver, so when the graph carries a contradiction
736 // (an odd cycle born of slop), the weakest link is the one disbelieved.
737 // Union-find with parity keeps the whole resolution order-independent.
738 // A pair whose runs split evenly still says the faces touch: it joins
739 // the components with the consistent-shell prior, at zero strength, so
740 // a fragment cannot drift off and choose its polarity alone.
741 let mut constraints: Vec<(u32, usize, usize, bool)> = votes
742 .iter()
743 .map(|(&(a, b), &(same, opposite))| (same.abs_diff(opposite), a, b, same > opposite))
744 .collect();
745 constraints.sort_by(|x, y| y.0.cmp(&x.0).then(x.1.cmp(&y.1)).then(x.2.cmp(&y.2)));
746
747 let mut parent: Vec<usize> = (0..pieces.len()).collect();
748 // Whether a piece is flipped relative to its parent.
749 let mut parity: Vec<bool> = vec![false; pieces.len()];
750 fn find(parent: &mut [usize], parity: &mut [bool], i: usize) -> (usize, bool) {
751 if parent[i] == i {
752 return (i, false);
753 }
754 let (root, above) = find(parent, parity, parent[i]);
755 parent[i] = root;
756 parity[i] ^= above;
757 (root, parity[i])
758 }
759 for &(_, a, b, same_direction) in &constraints {
760 let (ra, pa) = find(&mut parent, &mut parity, a);
761 let (rb, pb) = find(&mut parent, &mut parity, b);
762 // One of a same-direction pair flips: their parities must differ.
763 let need = same_direction;
764 if ra == rb {
765 // Agreeing or contradicting, the die is cast; a contradiction
766 // here lost to stronger evidence.
767 continue;
768 }
769 parent[rb] = ra;
770 parity[rb] = (pa != pb) != need;
771 }
772
773 let mut flip: Vec<bool> = vec![false; pieces.len()];
774 let mut components: std::collections::BTreeMap<usize, Vec<usize>> =
775 std::collections::BTreeMap::new();
776 for (i, f) in flip.iter_mut().enumerate() {
777 let (root, p) = find(&mut parent, &mut parity, i);
778 *f = p;
779 components.entry(root).or_default().push(i);
780 }
781 for members in components.values() {
782 let flipped = members.iter().filter(|&&i| flip[i]).count();
783 if flipped * 2 > members.len() {
784 for &i in members {
785 flip[i] = !flip[i];
786 }
787 }
788 }
789
790 for (i, &(t0, t1)) in pieces.iter().enumerate() {
791 if !flip[i] {
792 continue;
793 }
794 let mut flipped_vertices: Vec<u32> = Vec::new();
795 for t in &mut mesh.triangles[t0..t1] {
796 t.swap(1, 2);
797 flipped_vertices.extend_from_slice(&t[..]);
798 }
799 flipped_vertices.sort_unstable();
800 flipped_vertices.dedup();
801 for v in flipped_vertices {
802 mesh.normals[v as usize] = -mesh.normals[v as usize];
803 }
804 }
805}
806
807/// A face's trimming boundary, in its surface's parameter space.
808///
809/// The outer wire first, then any holes, each as a closed ring of `(u, v)`
810/// points with no repeated closing point. A face with no wires covers its
811/// surface's whole domain, and gets that rectangle as its boundary.
812///
813/// Public because parameter-space trimming is not only the triangulator's
814/// concern: classifying a point against a face, splitting a face in a boolean,
815/// and hidden-line removal all ask the same question of the same rings, and
816/// each deriving them separately would be three chances to disagree.
817///
818/// # Errors
819///
820/// As [`triangulate_face`].
821pub fn face_boundary(
822 model: &Model,
823 face: &Shape,
824 deflection: Deflection,
825 tol: Tolerances,
826) -> OgeomResult<Vec<Vec<Point2>>> {
827 deflection.validate()?;
828 if model.kind_of(face)? != ShapeType::Face {
829 ogeom_bail!(Construction, "expected a face");
830 }
831 let Some(node) = model.node(face) else {
832 ogeom_bail!(Dangling, "face is not in this model");
833 };
834 let NodeData::Face(data) = node.data() else {
835 ogeom_bail!(Construction, "face node holds no face data");
836 };
837 let Some(surface) = model.geometry().surface(data.surface) else {
838 ogeom_bail!(Dangling, "face refers to a surface not in this model");
839 };
840
841 Ok(trimming_rings(
842 model,
843 face,
844 data.surface,
845 surface,
846 deflection,
847 &EdgeChords::new(),
848 tol,
849 )?
850 .rings)
851}
852
853/// The rings bounding a face in parameter space, and whether every boundary
854/// edge met its deflection.
855/// Boundary rings with, per ring vertex, the 3D anchor its edge's own curve
856/// provides; `None` where an edge has no 3D curve to defer to.
857/// The chord each edge must be drawn with, where the caller's is too coarse.
858///
859/// Keyed by the edge's node, so both faces bounding it look the same value
860/// up and sample it identically. Absent means the caller's own chord.
861/// The chord each edge is to be drawn to, by edge node index, where a face
862/// wants its edges finer than the caller's chord: what every face sharing
863/// an edge has to agree on, or their meshes disagree along it.
864pub type EdgeChords = std::collections::HashMap<u32, f64>;
865
866/// How many times a face's boundary may be redrawn finer before its
867/// crossing is taken to be something the chord cannot fix.
868///
869/// Six halvings is a chord sixty-four times tighter than asked. A sliver
870/// still crossing itself there is degenerate in a way refinement does not
871/// reach (two boundaries genuinely on top of one another), and drawing it
872/// a seventh time only spends longer to say so.
873const REFINEMENTS: usize = 6;
874
875/// What one face needs its own edges drawn with.
876///
877/// A face narrower than the chord error its boundary is drawn with has a
878/// boundary that crosses *itself*. One body of a real assembly carries a
879/// quarter-arc sliver forty-five millimetres long and eighteen microns
880/// wide: at a tenth of a millimetre the sagitta of each bounding arc is
881/// twenty-nine microns, so the inner polyline bulges straight through the
882/// outer one, and what reaches the triangulator is not a region at all. It
883/// answered with sixteen triangles in fifteen disconnected pieces, and the
884/// holes between them were what stopped the body meshing closed.
885///
886/// The deflection a caller asks for bounds how far the mesh may sit from
887/// the surface; it is not a licence to hand the triangulator a polygon that
888/// crosses itself. Refining *lowers* that error, so this never breaks the
889/// caller's bound.
890///
891/// Only this face's own edges are named, and only when they need it.
892fn face_chords(
893 model: &Model,
894 face: &Shape,
895 id: ogeom_topo::SurfaceId,
896 surface: &SurfaceGeometry,
897 deflection: Deflection,
898 tol: Tolerances,
899) -> OgeomResult<EdgeChords> {
900 let mut finer = EdgeChords::new();
901 let mut chord = deflection.chord;
902 // A face narrower than a few chords first: its edges drawn to a
903 // fraction of its width, so the boundary's sag is small against it.
904 let first = trimming_rings(model, face, id, surface, deflection, &finer, tol)?.rings;
905 if let Some(want) = narrow_chord(surface, &first, deflection, tol) {
906 chord = want;
907 for edge in ogeom_topo::explore(model, face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
908 finer.insert(edge.node().index(), chord);
909 }
910 }
911 for _ in 0..=REFINEMENTS {
912 let rings = trimming_rings(model, face, id, surface, deflection, &finer, tol)?.rings;
913 let planar = triangulate_region(&rings, surface, deflection, tol)?;
914 let boundary: usize = rings.iter().map(Vec::len).sum();
915 if !planar.crossed && planar.triangles.len() + 4 >= boundary + 2 * rings.len() {
916 break;
917 }
918 chord *= 0.5;
919 for edge in ogeom_topo::explore(model, face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
920 finer.insert(edge.node().index(), chord);
921 }
922 }
923 Ok(finer)
924}
925
926/// How many chords wide a face must be for its edges to be drawn at the
927/// caller's chord; narrower, they are drawn at the width over this.
928///
929/// The boundary of a face drawn at chord `c` sags up to `c` between its
930/// points, and a triangle from that boundary across the face to the
931/// other side stands off the surface by the sag. On a face `w` wide that
932/// is `c / w` of the way to standing on end; held to a quarter, the
933/// triangles lean at most fourteen degrees.
934const NARROW: f64 = 4.0;
935
936/// The chord a face narrower than [`NARROW`] chords wants its edges drawn
937/// to, `None` for a face wide enough at the caller's.
938///
939/// Width is the rings' chart extent each way scaled by the mean tangent
940/// length that way, the smaller of the two: right for a strip along a
941/// chart axis, an over-estimate for one across the chart, which then
942/// keeps the caller's chord.
943fn narrow_chord(
944 surface: &SurfaceGeometry,
945 rings: &[Vec<Point2>],
946 deflection: Deflection,
947 tol: Tolerances,
948) -> Option<f64> {
949 let (lo, hi) = chart_extent(rings);
950 if !(lo.x.is_finite() && hi.x.is_finite() && lo.y.is_finite() && hi.y.is_finite()) {
951 return None;
952 }
953 let (mut du, mut dv, mut n) = (0.0_f64, 0.0_f64, 0usize);
954 for i in 0..=2 {
955 for j in 0..=2 {
956 let u = lo.x + (hi.x - lo.x) * (0.25 + 0.25 * f64::from(i));
957 let v = lo.y + (hi.y - lo.y) * (0.25 + 0.25 * f64::from(j));
958 if let Ok((a, b)) = surface.d1_at(u, v, tol) {
959 du += a.magnitude();
960 dv += b.magnitude();
961 n += 1;
962 }
963 }
964 }
965 if n == 0 {
966 return None;
967 }
968 #[allow(clippy::cast_precision_loss)]
969 let width = ((hi.x - lo.x) * du / n as f64).min((hi.y - lo.y) * dv / n as f64);
970 if !width.is_finite() || width <= tol.confusion() || width >= deflection.chord * NARROW {
971 return None;
972 }
973 Some(width / NARROW)
974}
975
976/// Whether every edge of `face` is already held to `want` or finer.
977fn edges_already_at(model: &Model, face: &Shape, finer: &EdgeChords, want: f64) -> bool {
978 ogeom_topo::explore(model, face, ogeom_topo::Filter::OfType(ShapeType::Edge)).is_ok_and(
979 |edges| {
980 edges
981 .iter()
982 .all(|e| finer.get(&e.node().index()).is_some_and(|&c| c <= want))
983 },
984 )
985}
986
987/// A face's trimming rings, walked, folded and cleaned.
988struct Trimming {
989 /// The outer ring first, then the holes, each closed without a repeated
990 /// closing point.
991 rings: Vec<Vec<Point2>>,
992 /// Each ring point's position in space where its edge's own curve put
993 /// it, `None` where it was made up.
994 anchors: Vec<Vec<Option<Point>>>,
995 /// Whether every edge's polyline honoured the deflection.
996 met: bool,
997 /// Each ring as its edges first walked it, chart and space together:
998 /// what a face whose rings all collapse is drawn from.
999 walked: Vec<Vec<(Point2, Point)>>,
1000}
1001
1002/// One walked ring: chart points, anchors, deflection honesty, the ambiguous
1003/// whole-period folds taken, and the half-period ties left undecided.
1004type WalkedRing = (
1005 Vec<Point2>,
1006 Vec<Option<Point>>,
1007 bool,
1008 Vec<(usize, f64)>,
1009 Vec<usize>,
1010);
1011
1012fn trimming_rings(
1013 model: &Model,
1014 face: &Shape,
1015 id: ogeom_topo::SurfaceId,
1016 surface: &SurfaceGeometry,
1017 deflection: Deflection,
1018 finer: &EdgeChords,
1019 tol: Tolerances,
1020) -> OgeomResult<Trimming> {
1021 let mut rings = Vec::new();
1022 let mut ring_anchors = Vec::new();
1023 let mut ring_folds: Vec<Vec<(usize, f64)>> = Vec::new();
1024 let mut ring_ties: Vec<Vec<usize>> = Vec::new();
1025 let mut met = true;
1026 let wires = model.ordered_children_of(face)?;
1027 let bounded = !wires.is_empty();
1028 let mut walked = Vec::new();
1029 for wire in wires {
1030 let (ring, anchors, ring_met, folds, ties) =
1031 boundary_ring(model, &wire, id, deflection, finer, tol)?;
1032 met &= ring_met;
1033 if ring.len() >= 3 {
1034 let mut here = Vec::with_capacity(ring.len());
1035 for (uv, anchor) in ring.iter().zip(&anchors) {
1036 let at = match anchor {
1037 Some(p) => *p,
1038 None => surface.point_at(uv.x, uv.y, tol)?,
1039 };
1040 here.push((*uv, at));
1041 }
1042 walked.push(here);
1043 rings.push(ring);
1044 ring_anchors.push(anchors);
1045 ring_folds.push(folds);
1046 ring_ties.push(ties);
1047 }
1048 }
1049
1050 // A band between two *wound* rings: each boundary winds a periodic
1051 // direction once (a bore wall whose ends are a full rim and a
1052 // staircase of arcs, a torus band between two parallels, a ball's
1053 // belt between two latitudes), and neither ring closes on its own. In
1054 // the unrolled chart the face is the strip between the two chains, so
1055 // the pair is merged into one ring by two joining runs standing
1056 // exactly one period apart: their lifted points are the same 3D
1057 // points, and the weld closes them the way it closes a seam. Paired
1058 // before either is closed on its own: a rim closed alone spans the
1059 // whole surface, and two of those cancel where they overlap.
1060 {
1061 use ogeom_geom::Surface as _;
1062 let ((ua, ub), (va, vb)) = surface.domain();
1063 let axes = [
1064 (surface.is_periodic_u(), ub - ua),
1065 (surface.is_periodic_v(), vb - va),
1066 ];
1067 for (axis, (periodic, period)) in axes.into_iter().enumerate() {
1068 if !periodic || period <= 0.0 {
1069 continue;
1070 }
1071 let along = |p: Point2| if axis == 0 { p.x } else { p.y };
1072 let across = |p: Point2| if axis == 0 { p.y } else { p.x };
1073 let make = |a: f64, c: f64| {
1074 if axis == 0 {
1075 Point2::new(a, c)
1076 } else {
1077 Point2::new(c, a)
1078 }
1079 };
1080 let d_of = |ring: &[Point2]| -> f64 {
1081 ring.last().map_or(0.0, |l| along(*l) - along(ring[0]))
1082 };
1083 let open_wound: Vec<usize> = rings
1084 .iter()
1085 .enumerate()
1086 .filter(|(_, ring)| {
1087 ring.len() >= 3
1088 && (d_of(ring).abs() - period).abs() <= period * 1e-3
1089 && ring
1090 .last()
1091 .is_some_and(|l| ring[0].distance(*l) > period * 0.5)
1092 })
1093 .map(|(i, _)| i)
1094 .collect();
1095 let [i, j] = open_wound[..] else {
1096 continue;
1097 };
1098 if d_of(&rings[i]).signum() == d_of(&rings[j]).signum() {
1099 continue;
1100 }
1101 let sign = d_of(&rings[i]).signum();
1102 // The column the joining runs stand on. Wherever the first rim's
1103 // chain happens to end is as good as anywhere for the rims
1104 // themselves, and no good at all when a third ring lies there:
1105 // a cross hole through a bore wall, whose loop the runs would
1106 // then cut through, two constraints refused and the face never
1107 // drawn whole. So the column is chosen where no other ring is,
1108 // in the widest gap the others leave round the period, and
1109 // both rims are re-cut to begin there: the same chains, the
1110 // same lifted points, begun a turn's fraction round.
1111 let column = {
1112 let mut spans: Vec<(f64, f64)> = Vec::new();
1113 for (k, ring) in rings.iter().enumerate() {
1114 if k == i || k == j {
1115 continue;
1116 }
1117 let (lo, hi) = ring
1118 .iter()
1119 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
1120 (lo.min(along(*p)), hi.max(along(*p)))
1121 });
1122 if lo.is_finite() && hi - lo < period {
1123 spans.push((lo.rem_euclid(period), hi - lo));
1124 }
1125 }
1126 let fallback = rings[i].last().map_or(0.0, |l| along(*l));
1127 if spans.is_empty() {
1128 fallback
1129 } else {
1130 // The widest gap between the spans' images on one turn.
1131 let mut edges: Vec<(f64, f64)> =
1132 spans.iter().map(|(lo, len)| (*lo, lo + len)).collect();
1133 edges.sort_by(|a, b| a.0.total_cmp(&b.0));
1134 let mut best = (f64::NEG_INFINITY, fallback);
1135 for k in 0..edges.len() {
1136 let end = edges[k].1;
1137 let next = if k + 1 < edges.len() {
1138 edges[k + 1].0
1139 } else {
1140 edges[0].0 + period
1141 };
1142 if next - end > best.0 {
1143 best = (next - end, f64::midpoint(end, next));
1144 }
1145 }
1146 best.1
1147 }
1148 };
1149 let recut = |chain: &mut Vec<Point2>, anchors: &mut Vec<Option<Point>>| {
1150 // The chain runs one period from its first point, either way
1151 // round; it is cut where it first reaches the column in its
1152 // own direction, and its head carried a period on to the
1153 // tail, closing on the same lifted point.
1154 let n = chain.len();
1155 if n < 3 {
1156 return;
1157 }
1158 let forward = d_of(chain) > 0.0;
1159 let start = along(chain[0]);
1160 let target = if forward {
1161 start + (column - start).rem_euclid(period)
1162 } else {
1163 start - (start - column).rem_euclid(period)
1164 };
1165 let reached = |k: usize| {
1166 if forward {
1167 along(chain[k]) >= target
1168 } else {
1169 along(chain[k]) <= target
1170 }
1171 };
1172 let Some(k) = (1..n - 1).find(|&k| reached(k)) else {
1173 return;
1174 };
1175 if k <= 1 {
1176 return;
1177 }
1178 let carry = if forward { period } else { -period };
1179 let mut turned: Vec<Point2> = chain[k..].to_vec();
1180 let mut turned_anchors: Vec<Option<Point>> = anchors[k..].to_vec();
1181 for (p, a) in chain[1..=k].iter().zip(&anchors[1..=k]) {
1182 turned.push(make(along(*p) + carry, across(*p)));
1183 turned_anchors.push(*a);
1184 }
1185 *chain = turned;
1186 *anchors = turned_anchors;
1187 };
1188 recut(&mut rings[i], &mut ring_anchors[i]);
1189 recut(&mut rings[j], &mut ring_anchors[j]);
1190 let b = rings.remove(j);
1191 let b_anchors = ring_anchors.remove(j);
1192 ring_folds.remove(j);
1193 ring_ties.remove(j);
1194 let a = &mut rings[i];
1195 let a_anchors = &mut ring_anchors[i];
1196 let a_last = a.last().copied().unwrap_or(a[0]);
1197 // Whole periods only: the second chain slides along the unrolled
1198 // chart until its start stands nearest the first chain's end.
1199 let shift = ((along(a_last) - along(b[0])) / period).round() * period;
1200 let b_first = make(along(b[0]) + shift, across(b[0]));
1201 let steps = 8;
1202 for k in 1..steps {
1203 let f = f64::from(k) / f64::from(steps);
1204 a.push(make(
1205 along(a_last) + (along(b_first) - along(a_last)) * f,
1206 across(a_last) + (across(b_first) - across(a_last)) * f,
1207 ));
1208 a_anchors.push(None);
1209 }
1210 for (p, anchor) in b.iter().zip(&b_anchors) {
1211 a.push(make(along(*p) + shift, across(*p)));
1212 a_anchors.push(*anchor);
1213 }
1214 // The way back: the same run, one period over, walked the other
1215 // way; the two runs lift to identical points.
1216 for k in (1..steps).rev() {
1217 let f = f64::from(k) / f64::from(steps);
1218 a.push(make(
1219 along(a_last) + (along(b_first) - along(a_last)) * f - sign * period,
1220 across(a_last) + (across(b_first) - across(a_last)) * f,
1221 ));
1222 a_anchors.push(None);
1223 }
1224 // The band now stands on one stretch of the chart, a period wide
1225 // from where the first rim was cut; every other ring (a hole
1226 // through the wall) is slid by whole periods into that stretch,
1227 // the same lifted points, or it stands outside the band it
1228 // belongs in.
1229 let (band_lo, band_hi) = a
1230 .iter()
1231 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
1232 (lo.min(along(*p)), hi.max(along(*p)))
1233 });
1234 for (k, ring) in rings.iter_mut().enumerate() {
1235 if k == i || ring.is_empty() {
1236 continue;
1237 }
1238 #[allow(clippy::cast_precision_loss)]
1239 let mean = ring.iter().map(|p| along(*p)).sum::<f64>() / ring.len() as f64;
1240 if mean >= band_lo && mean <= band_hi {
1241 continue;
1242 }
1243 let turns = ((mean - band_lo) / period).floor();
1244 if turns == 0.0 {
1245 continue;
1246 }
1247 for p in ring.iter_mut() {
1248 *p = make(along(*p) - turns * period, across(*p));
1249 }
1250 }
1251 }
1252 }
1253
1254 // A single ring still winding after the pairing had no partner, and if
1255 // it cannot close it was mis-folded: a jump of exactly one period is the
1256 // same 3D point, and the greedy fold that zeroed it may have wound the
1257 // ring instead. Undoing one such ambiguous fold (translating the walk
1258 // from there on by a period) closes the ring; the join it reopens
1259 // still lifts to a single point.
1260 if geometry_winds(surface) {
1261 use ogeom_geom::Surface as _;
1262 let ((ua, ub), _) = surface.domain();
1263 let period = ub - ua;
1264 for ((ring, folds), ties) in rings.iter_mut().zip(&ring_folds).zip(&ring_ties) {
1265 let Some(last) = ring.last().copied() else {
1266 continue;
1267 };
1268 let du = last.x - ring[0].x;
1269 let k = (du / period).round();
1270 if k == 0.0
1271 || (du - k * period).abs() > period * 1e-3
1272 || ring[0].distance(last) <= period * 1e-3
1273 {
1274 continue;
1275 }
1276 // The last ambiguous fold whose applied shift matches the
1277 // winding is the one to undo.
1278 if let Some(&(fold_start, _)) = folds
1279 .iter()
1280 .rev()
1281 .find(|(_, shift)| (shift - k * period).abs() <= period * 1e-6)
1282 && fold_start < ring.len()
1283 {
1284 // Translating from the last undecided tie before the fold
1285 // (where the walk first guessed) keeps the period jump on
1286 // the degenerate row, where it lifts to nothing.
1287 let start = ties
1288 .iter()
1289 .rev()
1290 .find(|&&t| t < fold_start)
1291 .copied()
1292 .unwrap_or(fold_start);
1293 for p in &mut ring[start..] {
1294 p.x -= k * period;
1295 }
1296 }
1297 }
1298 }
1299
1300 // A ring still winding after all that closes on its own: against a
1301 // pole row, or its own translate a period over the other way.
1302 for (ring, anchors) in rings.iter_mut().zip(ring_anchors.iter_mut()) {
1303 close_wound_ring(ring, anchors, surface, tol);
1304 }
1305
1306 // Points closer than the triangulator's own resolution make it refuse
1307 // the constraint; a real file's chart can carry them. The consecutive
1308 // near-duplicates collapse, anchors staying aligned.
1309 for (ring, anchors) in rings.iter_mut().zip(ring_anchors.iter_mut()) {
1310 if ring.len() < 3 {
1311 continue;
1312 }
1313 let mut extent = 0.0_f64;
1314 for pair in ring.windows(2) {
1315 extent = extent.max(pair[0].distance(pair[1]));
1316 }
1317 let eps = extent.mul_add(1e-9, 1e-12);
1318 let mut kept_ring = Vec::with_capacity(ring.len());
1319 let mut kept_anchors = Vec::with_capacity(anchors.len());
1320 for (p, a) in ring.iter().zip(anchors.iter()) {
1321 if kept_ring
1322 .last()
1323 .is_some_and(|held: &Point2| held.distance(*p) <= eps)
1324 {
1325 continue;
1326 }
1327 kept_ring.push(*p);
1328 kept_anchors.push(*a);
1329 }
1330 if kept_ring.len() > 2
1331 && let (Some(first), Some(last)) = (kept_ring.first(), kept_ring.last())
1332 && first.distance(*last) <= eps
1333 {
1334 kept_ring.pop();
1335 kept_anchors.pop();
1336 }
1337 *ring = kept_ring;
1338 *anchors = kept_anchors;
1339 }
1340 // Folded across a join, a ring on a *closed* surface can come to rest a
1341 // whole period outside the domain. A periodic surface would not mind
1342 // (it wraps), but a surface that merely closes on itself evaluates only
1343 // where its knots are, and refuses everywhere else; three bodies of one
1344 // assembly stopped meshing that way, every point of one ring a turn
1345 // past the end. The fold kept the ring continuous, which is the part
1346 // that matters, and a rigid slide by whole periods keeps it so: the
1347 // same points on the surface, named inside the chart.
1348 {
1349 use ogeom_geom::Surface as _;
1350 let ((ua, ub), (va, vb)) = surface.domain();
1351 let slides = [
1352 (!surface.is_periodic_u() && surface.is_closed_u(tol), ua, ub),
1353 (!surface.is_periodic_v() && surface.is_closed_v(tol), va, vb),
1354 ];
1355 for (across, (closed, lo, hi)) in [true, false].into_iter().zip(slides) {
1356 if !closed || hi <= lo {
1357 continue;
1358 }
1359 let span = hi - lo;
1360 for ring in &mut rings {
1361 let read = |p: &Point2| if across { p.x } else { p.y };
1362 let (least, most) = ring
1363 .iter()
1364 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| {
1365 (a.min(read(p)), b.max(read(p)))
1366 });
1367 if !(least.is_finite() && most.is_finite()) || most - least > span * (1.0 + 1e-9) {
1368 continue;
1369 }
1370 // A hair past the end is fit noise, not a period: the knots
1371 // take it, and rounding it up to a whole turn would carry the
1372 // ring a period the wrong way, which is exactly what it did
1373 // to the face this was written for, before the slack.
1374 let slack = span * 1e-6;
1375 let turns = if least < lo - slack {
1376 ((lo - least) / span).ceil()
1377 } else if most > hi + slack {
1378 -((most - hi) / span).ceil()
1379 } else {
1380 0.0
1381 };
1382 if turns == 0.0 {
1383 continue;
1384 }
1385 for p in ring.iter_mut() {
1386 if across {
1387 p.x += turns * span;
1388 } else {
1389 p.y += turns * span;
1390 }
1391 }
1392 }
1393 }
1394 }
1395 if *MESH_DEBUG {
1396 for (i, (ring, anchors)) in rings.iter().zip(&ring_anchors).enumerate() {
1397 eprintln!("DBG ring {i}: {} points", ring.len());
1398 for (p, a) in ring.iter().zip(anchors) {
1399 eprintln!(
1400 "DBG uv({:.5},{:.5}) anchor {}",
1401 p.x,
1402 p.y,
1403 a.map_or("-".to_string(), |q| format!(
1404 "({:.4},{:.4},{:.4})",
1405 q.x, q.y, q.z
1406 ))
1407 );
1408 }
1409 }
1410 }
1411 // A run out along an edge and straight back along it (a ring that
1412 // reads `p, q, p`) is a spike into the region that bounds nothing:
1413 // the file's way of drawing a slit of no width at all on the face's
1414 // own boundary. It triangulates to two hairs and one vertex too many,
1415 // which is one triangle more than a boundary that encloses a region
1416 // has, and it is not a crossing. Off it comes, out to in, until the
1417 // ring reverses nowhere.
1418 // And two consecutive points a millionth of the ring's size apart are
1419 // one point: the end of the last edge and the start of the first, each
1420 // where its own curve put the shared vertex, a file's slop apart. Kept
1421 // both, the second sits on the first's next segment to the last bit,
1422 // and a constraint through a vertex is one the triangulation refuses.
1423 for (ring, anchors) in rings.iter_mut().zip(ring_anchors.iter_mut()) {
1424 let reach = chart_reach(ring);
1425 merge_near_duplicates(ring, anchors, reach);
1426 remove_spikes(ring, anchors, reach);
1427 }
1428 rings.retain(|r| r.len() >= 3);
1429 ring_anchors.retain(|a| a.len() >= 3);
1430
1431 // An inner ring thinner than a micron is a slit, not a hole. A real file
1432 // draws one by running out along two arcs and back along two splines
1433 // fitted to the same arcs: a loop three millimetres long and a fifth of
1434 // a micron wide, enclosing nothing, which the triangulator can only
1435 // read as a tangle: one face carrying five of them drew with twelve
1436 // holes it does not have. Measured in space through the ring's own
1437 // anchors, so a chart's units do not enter into it; a ring not anchored
1438 // end to end is left alone, and so is the outer ring, whatever its
1439 // width, since a face that is itself a slit is a different question.
1440 // The thinnest real feature in the assembly that showed this is twenty
1441 // microns across, twenty times the cutoff.
1442 if rings.len() > 1 {
1443 let chart_area = |ring: &[Point2]| -> f64 {
1444 let mut a = 0.0;
1445 for i in 0..ring.len() {
1446 let (p, q) = (ring[i], ring[(i + 1) % ring.len()]);
1447 a += p.x * q.y - q.x * p.y;
1448 }
1449 a.abs()
1450 };
1451 let outer = (0..rings.len())
1452 .max_by(|&i, &j| chart_area(&rings[i]).total_cmp(&chart_area(&rings[j])))
1453 .unwrap_or(0);
1454 let width = |anchors: &[Option<Point>]| -> Option<f64> {
1455 let pts: Option<Vec<Point>> = anchors.iter().copied().collect();
1456 let pts = pts?;
1457 let mut normal = Vector::ZERO;
1458 let mut perimeter = 0.0;
1459 for i in 0..pts.len() {
1460 let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
1461 normal += a.to_vector().cross(b.to_vector());
1462 perimeter += a.distance(b);
1463 }
1464 (perimeter > 0.0).then(|| normal.magnitude() * 0.5 / perimeter)
1465 };
1466 let keep: Vec<bool> = (0..rings.len())
1467 .map(|i| {
1468 i == outer || width(&ring_anchors[i]).is_none_or(|w| w >= tol.confusion() * 1e4)
1469 })
1470 .collect();
1471 let mut it = keep.iter();
1472 rings.retain(|_| *it.next().unwrap_or(&true));
1473 let mut it = keep.iter();
1474 ring_anchors.retain(|_| *it.next().unwrap_or(&true));
1475 }
1476
1477 // A face with no wires covers its surface's whole domain, so the domain
1478 // rectangle is the boundary. A face whose wires all collapse (a sliver
1479 // narrower than a millionth of its own length, its two long sides one
1480 // line in the chart) encloses nothing, and keeps no ring at all: taken
1481 // for a face without wires, it would be drawn as the whole plane.
1482 if rings.is_empty() && !bounded {
1483 let ring = domain_ring(surface, deflection, tol);
1484 ring_anchors.push(vec![None; ring.len()]);
1485 rings.push(ring);
1486 }
1487 Ok(Trimming {
1488 rings,
1489 anchors: ring_anchors,
1490 met,
1491 walked,
1492 })
1493}
1494
1495/// A face with no area as a fan of triangles across each of its walked
1496/// rings, every point where its edges put it.
1497fn sliver_fan(
1498 walked: &[Vec<(Point2, Point)>],
1499 surface: &SurfaceGeometry,
1500 tol: Tolerances,
1501) -> Triangulation {
1502 let mut mesh = Triangulation::new();
1503 for ring in walked {
1504 let base = u32::try_from(mesh.positions.len()).unwrap_or(u32::MAX);
1505 for (uv, at) in ring {
1506 mesh.positions.push(*at);
1507 mesh.parameters.push((uv.x, uv.y));
1508 mesh.normals.push(
1509 surface
1510 .normal_at(uv.x, uv.y, tol)
1511 .map_or(ogeom_math::Vector::Z, |n| n.vector()),
1512 );
1513 }
1514 let count = u32::try_from(ring.len()).unwrap_or(0);
1515 for k in 1..count.saturating_sub(1) {
1516 mesh.triangles.push([base, base + k, base + k + 1]);
1517 }
1518 }
1519 mesh
1520}
1521
1522/// Within this of each other, two chart points of a ring are one point: a
1523/// millionth of the ring's extent, well under any feature and well over
1524/// the slop two edges leave at the vertex they share.
1525fn chart_reach(ring: &[Point2]) -> f64 {
1526 let (mut lo, mut hi) = (
1527 Point2::new(f64::INFINITY, f64::INFINITY),
1528 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
1529 );
1530 for p in ring {
1531 lo = Point2::new(lo.x.min(p.x), lo.y.min(p.y));
1532 hi = Point2::new(hi.x.max(p.x), hi.y.max(p.y));
1533 }
1534 let extent = (hi.x - lo.x).max(hi.y - lo.y);
1535 if extent.is_finite() && extent > 0.0 {
1536 extent * 1e-6
1537 } else {
1538 0.0
1539 }
1540}
1541
1542/// Merge consecutive ring points within `reach` of each other,
1543/// cyclically; the earlier point and its anchor stay.
1544fn merge_near_duplicates(ring: &mut Vec<Point2>, anchors: &mut Vec<Option<Point>>, reach: f64) {
1545 if ring.len() < 2 || anchors.len() != ring.len() || reach <= 0.0 {
1546 return;
1547 }
1548 let mut i = 0;
1549 while i < ring.len() && ring.len() >= 2 {
1550 let next = (i + 1) % ring.len();
1551 let (p, q) = (ring[i], ring[next]);
1552 if (p.x - q.x).hypot(p.y - q.y) <= reach {
1553 // The later point goes (the last one when the ring's end
1554 // repeats its start), and the earlier is looked at again, in
1555 // case it sits next to another near-duplicate.
1556 if next == 0 {
1557 ring.remove(i);
1558 anchors.remove(i);
1559 break;
1560 }
1561 ring.remove(next);
1562 anchors.remove(next);
1563 } else {
1564 i += 1;
1565 }
1566 }
1567}
1568
1569/// Strip every `p, q, p` from a ring (a point stepped out to and straight
1570/// back from, the two `p` within `reach` of each other), with its anchors,
1571/// until none is left. Cyclic: the ring's last point is its first's
1572/// neighbour.
1573fn remove_spikes(ring: &mut Vec<Point2>, anchors: &mut Vec<Option<Point>>, reach: f64) {
1574 loop {
1575 let n = ring.len();
1576 if n < 3 || anchors.len() != n {
1577 return;
1578 }
1579 let Some(tip) = (0..n).find(|&i| {
1580 let (before, after) = (ring[(i + n - 1) % n], ring[(i + 1) % n]);
1581 (before.x - after.x).hypot(before.y - after.y) <= reach
1582 }) else {
1583 return;
1584 };
1585 // The tip and one of its two identical neighbours go.
1586 let neighbour = (tip + 1) % n;
1587 let (first, second) = if tip < neighbour {
1588 (neighbour, tip)
1589 } else {
1590 (tip, neighbour)
1591 };
1592 ring.remove(first);
1593 anchors.remove(first);
1594 ring.remove(second);
1595 anchors.remove(second);
1596 }
1597}
1598
1599/// Whether a surface is periodic in `u` alone: the charts on which a wound
1600/// ring cannot close against a translate of itself and needs a partner.
1601fn geometry_winds(surface: &SurfaceGeometry) -> bool {
1602 use ogeom_geom::Surface as _;
1603 surface.is_periodic_u() && !surface.is_periodic_v()
1604}
1605
1606/// Whether a point in parameter space lies inside the region `rings` bound.
1607///
1608/// Even-odd winding: inside the outer ring and outside every hole. The rings
1609/// come from wires whose direction already encodes outer from inner, but
1610/// counting crossings does not depend on that being right, which makes it
1611/// robust to a wire that was built the wrong way round.
1612///
1613/// Says nothing about a point *on* a ring: the crossing count of a boundary
1614/// point is whichever side rounding puts it. A caller that cares has to measure
1615/// its distance to the boundary and decide, which is what classification does.
1616///
1617/// Decided with exact predicates. See [`inside_boundary_with`].
1618#[must_use]
1619pub fn inside_boundary(rings: &[Vec<Point2>], p: Point2) -> bool {
1620 inside_boundary_with::<Exact>(rings, p)
1621}
1622
1623/// As [`inside_boundary`], with the predicate implementation named.
1624///
1625/// This is the seam `docs/DATA_MODEL.md` §9 describes, and it is here rather
1626/// than anywhere else because this is where the *combinatorial* decision is.
1627/// Whether a point is inside a ring is not a measurement that can be a little
1628/// wrong: it decides whether a triangle is kept or dropped, so an error near a
1629/// boundary is a hole in the mesh rather than a slightly misplaced one.
1630///
1631/// The naive form divides to find where an edge crosses the sampling ray, and
1632/// that division cancels catastrophically for a point nearly on the edge.
1633/// `orient2d` answers the same question with no division at all, and
1634/// [`Exact`] answers it correctly however close the point is.
1635#[must_use]
1636pub fn inside_boundary_with<P: Predicates>(rings: &[Vec<Point2>], p: Point2) -> bool {
1637 let mut inside = false;
1638 for ring in rings {
1639 if crosses_odd_times::<P>(ring, p) {
1640 inside = !inside;
1641 }
1642 }
1643 inside
1644}
1645
1646/// The boundary of one wire, in the face's parameter space.
1647///
1648/// Each edge is discretized in *space* and its pcurve evaluated at the resulting
1649/// parameters, so two faces sharing the edge agree on where its points are.
1650fn boundary_ring(
1651 model: &Model,
1652 wire: &Shape,
1653 surface: ogeom_topo::SurfaceId,
1654 deflection: Deflection,
1655 finer: &EdgeChords,
1656 tol: Tolerances,
1657) -> OgeomResult<WalkedRing> {
1658 let mut ring: Vec<Point2> = Vec::new();
1659 let mut anchors: Vec<Option<Point>> = Vec::new();
1660 // Ambiguous folds: where an edge was translated a whole period to
1661 // continue the walk from a start that already coincided modulo the
1662 // period; the fold was one of two defensible choices, recorded so a
1663 // mis-wound ring can be unwound.
1664 let mut folds: Vec<(usize, f64)> = Vec::new();
1665 // Half-period jumps whose side could not be decided when walked.
1666 let mut ties: Vec<usize> = Vec::new();
1667 let mut met = true;
1668 // Whether each chart direction comes back on itself: periodic, or
1669 // closed without repeating. Asked once here: closure on a spline is a
1670 // walk down a control column, and asking it at every edge of every face
1671 // of a hundred-thousand-face assembly was a tenth of the meshing time.
1672 let (wraps_u, wraps_v) = model
1673 .geometry()
1674 .surface(surface)
1675 .map_or((false, false), |g| {
1676 use ogeom_geom::Surface as _;
1677 (
1678 g.is_periodic_u() || g.is_closed_u(tol),
1679 g.is_periodic_v() || g.is_closed_v(tol),
1680 )
1681 });
1682
1683 // Start the walk off a seam if the wire allows it: a seam's side is
1684 // chosen by continuity with the point already walked to, and continuity
1685 // needs something to continue from. The ring is cyclic, so rotating the
1686 // walk changes nothing it reports.
1687 let mut children = model.ordered_children_of(wire)?;
1688 let is_seam = |model: &Model, e: &Shape| -> bool {
1689 model
1690 .node(e)
1691 .and_then(|n| n.data().as_edge())
1692 .and_then(|d| d.pcurve_for(surface, e.location()))
1693 .is_some_and(|r| matches!(r, EdgeRepr::Seam { .. }))
1694 };
1695 if let Some(start) = children.iter().position(|e| !is_seam(model, e)) {
1696 children.rotate_left(start);
1697 }
1698
1699 // The column each seam edge's first traversal effectively walked (after
1700 // folding), and whether its two sides differ in u or in v. A seam bounds
1701 // its face twice, and the two traversals must bracket the ring exactly
1702 // one period apart; the walk checks the second against this record.
1703 let mut seam_walked: std::collections::HashMap<ogeom_topo::TShapeId, Point2> =
1704 std::collections::HashMap::new();
1705
1706 for edge in children {
1707 let Some(node) = model.node(&edge) else {
1708 ogeom_bail!(Dangling, "edge is not in this model");
1709 };
1710 let NodeData::Edge(data) = node.data() else {
1711 ogeom_bail!(Construction, "edge node holds no edge data");
1712 };
1713 // Whether this edge is a seam, and if so whether its two sides
1714 // differ in u (true) or in v.
1715 let mut seam: Option<bool> = None;
1716 let (pcurve_id, pcurve_range) = match data.pcurve_for(surface, edge.location()) {
1717 Some(EdgeRepr::PCurve { curve, range, .. }) => (*curve, *range),
1718 // A seam edge runs along a closed surface's join and bounds its
1719 // face twice: up one side of the parameter rectangle and down
1720 // the other. Which side this occurrence takes is decided by the
1721 // ring itself: the side whose oriented start continues the point
1722 // already walked to. Orientation flags cannot answer it (a
1723 // reversed face flips every occurrence while the chart columns
1724 // stay where they were built), but the chart can.
1725 Some(EdgeRepr::Seam {
1726 forward,
1727 reversed,
1728 range,
1729 ..
1730 }) => {
1731 let (f, r) = (*forward, *reversed);
1732 let side_start = |id: ogeom_topo::PCurveId| -> Option<Point2> {
1733 model.geometry().pcurve(id)?.point_at(range.0, tol).ok()
1734 };
1735 seam = Some(match (side_start(f), side_start(r)) {
1736 (Some(a), Some(b)) => (a.x - b.x).abs() >= (a.y - b.y).abs(),
1737 _ => true,
1738 });
1739 let picked = if let Some(last) = ring.last().copied() {
1740 let start_of = |id: ogeom_topo::PCurveId| -> Option<Point2> {
1741 let pc = model.geometry().pcurve(id)?;
1742 let t = if edge.orientation() == Orientation::Reversed {
1743 range.1
1744 } else {
1745 range.0
1746 };
1747 pc.point_at(t, tol).ok()
1748 };
1749 match (start_of(f), start_of(r)) {
1750 (Some(a), Some(b)) => {
1751 if last.distance(a) <= last.distance(b) {
1752 f
1753 } else {
1754 r
1755 }
1756 }
1757 _ => f,
1758 }
1759 } else if edge.orientation() == Orientation::Reversed {
1760 r
1761 } else {
1762 f
1763 };
1764 (picked, *range)
1765 }
1766 _ => ogeom_bail!(
1767 Construction,
1768 "edge has no pcurve on this face, so the face cannot be \
1769 triangulated in its own parameter space"
1770 ),
1771 };
1772 let Some(pcurve) = model.geometry().pcurve(pcurve_id) else {
1773 ogeom_bail!(Dangling, "pcurve is not in this model");
1774 };
1775
1776 // Sample where the *3D* curve says to, so an adjacent face lands on
1777 // the same points, and *anchor* the boundary vertices to that curve
1778 // too: two faces sharing an edge lift the same parameters through
1779 // different surfaces, and on an imported file those surfaces
1780 // disagree by the file's own slop. The edge is the shared authority,
1781 // so its points are the positions both faces use, and the weld is a
1782 // matter of identity rather than luck.
1783 let mut edge_anchors: Vec<Option<Point>> = Vec::new();
1784 // An edge drawn finer is drawn finer for *every* face that bounds
1785 // it, which is the whole point: the two sides must agree point for
1786 // point or the weld has nothing to join.
1787 let along = match finer.get(&edge.node().index()) {
1788 Some(chord) => Deflection {
1789 chord: *chord,
1790 ..deflection
1791 },
1792 None => deflection,
1793 };
1794 let samples = match sample_parameters(model, data, along, tol)? {
1795 Some((parameters, edge_met)) => {
1796 met &= edge_met;
1797 if let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d()
1798 && let Some(geometry) = model.geometry().curve(*curve)
1799 && let Ok(edge_placement) = edge.transform(model.datums())
1800 {
1801 for t in ¶meters {
1802 edge_anchors.push(
1803 geometry
1804 .point_at(*t, tol)
1805 .ok()
1806 .map(|p| edge_placement.apply(p)),
1807 );
1808 }
1809 }
1810 map_to_pcurve(¶meters, data, pcurve_range)
1811 }
1812 // No 3D curve to defer to: the pcurve's own shape, measured in
1813 // space through the surface, so the chord tolerance means the
1814 // same thing it means everywhere else.
1815 None => {
1816 let Some(geometry) = model.geometry().surface(surface) else {
1817 ogeom_bail!(Dangling, "face refers to a surface not in this model");
1818 };
1819 let (_, parameters) = crate::discretize::discretize_on_surface(
1820 pcurve,
1821 pcurve_range,
1822 geometry,
1823 deflection,
1824 tol,
1825 )?;
1826 parameters
1827 }
1828 };
1829
1830 let mut points: Vec<Point2> = samples
1831 .iter()
1832 .map(|u| pcurve.point_at(*u, tol))
1833 .collect::<OgeomResult<_>>()?;
1834 if edge.orientation() == Orientation::Reversed {
1835 points.reverse();
1836 edge_anchors.reverse();
1837 }
1838 if edge_anchors.len() != points.len() {
1839 edge_anchors = vec![None; points.len()];
1840 }
1841 // The ends of an edge belong to its *vertices* (the one authority
1842 // every face and every neighbouring edge shares), but only within the
1843 // tolerance the vertex itself records. An imported curve ends within
1844 // the vertex's widened tolerance of it, and lifting the ends through
1845 // the curve alone would leave each corner split as many ways as there
1846 // are curves meeting there; a vertex that sits *beyond* its stated
1847 // tolerance from the curve is not describing the curve's end at all,
1848 // and the curve stays the authority.
1849 if !points.is_empty()
1850 && let Ok(vs) = model.children_of(&edge)
1851 && vs.len() >= 2
1852 && let Ok(edge_placement) = edge.transform(model.datums())
1853 {
1854 let point_of = |v: &Shape| -> Option<(Point, f64)> {
1855 let data = model.node(v)?.data().as_vertex()?;
1856 Some((edge_placement.apply(data.point), data.tolerance.get()))
1857 };
1858 let (from, to) = if edge.orientation() == Orientation::Reversed {
1859 (&vs[vs.len() - 1], &vs[0])
1860 } else {
1861 (&vs[0], &vs[vs.len() - 1])
1862 };
1863 if let Some((p, within)) = point_of(from)
1864 && let Some(a) = edge_anchors.first_mut()
1865 && a.is_none_or(|end| end.distance(p) <= within + tol.confusion())
1866 {
1867 *a = Some(p);
1868 }
1869 if let Some((p, within)) = point_of(to)
1870 && let Some(a) = edge_anchors.last_mut()
1871 && a.is_none_or(|end| end.distance(p) <= within + tol.confusion())
1872 {
1873 *a = Some(p);
1874 }
1875 }
1876 // Fold onto the branch that continues the ring. Two faces sharing an
1877 // edge share its pcurve, and on a periodic surface the pcurve sits in
1878 // *one* face's window: a cylinder split into two halves has a ruling
1879 // at u = 0 that the other half needs at u = 2pi. The chart cannot
1880 // store both; continuity with the ring being walked recovers the
1881 // right branch, exactly as the seam sides are chosen.
1882 if let Some(last) = ring.last().copied()
1883 && let Some(first) = points.first().copied()
1884 && let Some(geometry) = model.geometry().surface(surface)
1885 {
1886 use ogeom_geom::Surface as _;
1887 let ((ua, ub), (va, vb)) = geometry.domain();
1888 // Nearest whole period, ties broken toward *not moving*: a jump
1889 // of exactly half a period is what a boundary crossing a
1890 // degenerate row looks like (two rulings into an apex stand
1891 // half a turn apart, and the connecting run along the apex row
1892 // lifts to nothing), and folding it would drag the edge a full
1893 // period from the column its own projection put it on.
1894 let whole_periods = |gap: f64, span: f64| -> f64 {
1895 let r = gap / span;
1896 if (r.fract().abs() - 0.5).abs() <= 1e-9 {
1897 r.trunc() * span
1898 } else {
1899 r.round() * span
1900 }
1901 };
1902 let mut shift = Point2::new(0.0, 0.0);
1903 // A repeated seam edge folds like any other, but never onto its
1904 // own first traversal. The two traversals bound the face up one
1905 // side of the chart and down the other, one period apart, and at
1906 // a degenerate row continuity cannot say so: a cone walked to
1907 // its apex reaches a corner that maps to the whole row, both
1908 // sides continue it equally, and folding by nearness closes the
1909 // ring over nothing. The record decides instead: land exactly a
1910 // period from the first walk, on the side the ring occupies.
1911 let prior = seam.and_then(|_| seam_walked.get(&edge.node())).copied();
1912 if wraps_u && (ub - ua) > 0.0 {
1913 let span = ub - ua;
1914 let gap = last.x - first.x;
1915 shift.x = whole_periods(gap, span);
1916 let mut bracketed = false;
1917 if seam == Some(true)
1918 && let Some(prior) = prior
1919 && (first.x + shift.x - prior.x).abs() < span * 0.5
1920 {
1921 let (lo, hi) = ring
1922 .iter()
1923 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
1924 (lo.min(p.x), hi.max(p.x))
1925 });
1926 // Bracketing is for the face that wraps the period: a
1927 // band whose rims run the whole way round, its two seam
1928 // columns a period apart. A *slit* uses one edge twice
1929 // without wrapping: the ring stays in a fraction of the
1930 // chart, both traversals stand on one column, and
1931 // forcing them apart winds the ring, invites a pole row
1932 // it never touches, and meshes the complement of the
1933 // face. The ring's own reach
1934 // says which face this is.
1935 if hi - lo >= span * 0.5 {
1936 let side = if f64::midpoint(lo, hi) >= prior.x {
1937 1.0
1938 } else {
1939 -1.0
1940 };
1941 shift.x = prior.x + side * span - first.x;
1942 bracketed = true;
1943 }
1944 }
1945 if !bracketed {
1946 if shift.x != 0.0 && (gap - shift.x).abs() <= span * 1e-6 {
1947 // The start already stood a whole period from the walk
1948 // (the same 3D point), so this fold is a choice, not a
1949 // repair; recorded so a mis-wound ring can be unwound.
1950 folds.push((ring.len(), shift.x));
1951 } else if ((gap / span).fract().abs() - 0.5).abs() <= 1e-9 {
1952 // A half-period tie: either side of the degenerate row
1953 // was defensible, and if the ring comes out wound the
1954 // unwinding starts here rather than at the later fold.
1955 ties.push(ring.len());
1956 }
1957 }
1958 }
1959 if wraps_v && (vb - va) > 0.0 {
1960 let span = vb - va;
1961 shift.y = whole_periods(last.y - first.y, span);
1962 if seam == Some(false)
1963 && let Some(prior) = prior
1964 && (first.y + shift.y - prior.y).abs() < span * 0.5
1965 {
1966 let (lo, hi) = ring
1967 .iter()
1968 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
1969 (lo.min(p.y), hi.max(p.y))
1970 });
1971 let side = if f64::midpoint(lo, hi) >= prior.y {
1972 1.0
1973 } else {
1974 -1.0
1975 };
1976 shift.y = prior.y + side * span - first.y;
1977 }
1978 }
1979 if shift.x != 0.0 || shift.y != 0.0 {
1980 for p in &mut points {
1981 p.x += shift.x;
1982 p.y += shift.y;
1983 }
1984 }
1985 }
1986 if seam.is_some()
1987 && let Some(first) = points.first().copied()
1988 {
1989 seam_walked.entry(edge.node()).or_insert(first);
1990 }
1991 // The previous edge already contributed the shared vertex, but only
1992 // where the chart agrees it is shared. Two rulings meeting at an
1993 // apex share the *vertex* while standing apart in the chart, and the
1994 // run between them along the degenerate row is boundary the ring
1995 // needs: dropping its start would cut the corner straight through
1996 // the face's interior.
1997 //
1998 // Two things have to hold for a gap to be such a run, and neither
1999 // alone is enough. It has to be wide: two ends that disagree by the
2000 // file's slop stand a micron over a radius apart, and a thousandth
2001 // of the period is three orders above that. And what lies between
2002 // has to be degenerate: a run along an apex row lifts to one point
2003 // the whole way, so its chart midpoint lands on the shared vertex,
2004 // where a wide gap on a live row lifts to somewhere the width of the
2005 // gap away. Width alone kept slop on fitted splines; the lift alone
2006 // kept every gap too small for its midpoint to land anywhere else.
2007 // And no fraction of the period alone is right at all: one real
2008 // part's rulings stand exactly a quarter turn apart, which a
2009 // quarter-period test read as not apart, and the face lost the
2010 // triangle at its apex.
2011 let keep_gap = if let (Some(last), Some(first)) = (ring.last(), points.first()) {
2012 model.geometry().surface(surface).is_some_and(|geometry| {
2013 use ogeom_geom::Surface as _;
2014 let ((ua, ub), (va, vb)) = geometry.domain();
2015 let wide = (wraps_u && (last.x - first.x).abs() > (ub - ua) * 1e-3)
2016 || (wraps_v && (last.y - first.y).abs() > (vb - va) * 1e-3);
2017 if !wide {
2018 return false;
2019 }
2020 let mid = Point2::new(
2021 f64::midpoint(last.x, first.x),
2022 f64::midpoint(last.y, first.y),
2023 );
2024 let reach = tol.confusion() * 1e4;
2025 match (
2026 geometry.point_at(last.x, last.y, tol),
2027 geometry.point_at(mid.x, mid.y, tol),
2028 geometry.point_at(first.x, first.y, tol),
2029 ) {
2030 (Ok(a), Ok(m), Ok(b)) => a.distance(m) <= reach && m.distance(b) <= reach,
2031 _ => false,
2032 }
2033 })
2034 } else {
2035 false
2036 };
2037 if !ring.is_empty() && !points.is_empty() && !keep_gap {
2038 points.remove(0);
2039 edge_anchors.remove(0);
2040 }
2041 ring.extend(points);
2042 anchors.extend(edge_anchors);
2043 }
2044
2045 // A closed ring repeats its first point at the end; the triangulator wants
2046 // it named once.
2047 if ring.len() > 2
2048 && let (Some(first), Some(last)) = (ring.first().copied(), ring.last().copied())
2049 {
2050 // Equal in the chart, or the same vertex in space: the last edge's
2051 // curve ends where the first edge's begins to within the file's
2052 // slop: up to ten microns in a real assembly, recorded on the
2053 // vertex as its widened tolerance. Kept as two points, the ring
2054 // closes with a fold back over its own first segment, a crossing
2055 // a fraction of a micron deep that the triangulation refuses as a
2056 // constraint and the face is then drawn six times finer for.
2057 // The same vertex in space is not enough on its own: a ring that
2058 // winds a periodic chart ends a whole period from where it began
2059 // and lifts to the same point, and that closing is a seam, not
2060 // slop. Close in the chart too (within a thousandth of the ring's
2061 // own extent), or the ring is left to the winding rule below.
2062 let extent = ring.iter().fold(
2063 (
2064 Point2::new(f64::INFINITY, f64::INFINITY),
2065 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
2066 ),
2067 |(lo, hi), p| {
2068 (
2069 Point2::new(lo.x.min(p.x), lo.y.min(p.y)),
2070 Point2::new(hi.x.max(p.x), hi.y.max(p.y)),
2071 )
2072 },
2073 );
2074 let extent = (extent.1.x - extent.0.x).max(extent.1.y - extent.0.y);
2075 let near_in_chart = (first.x - last.x).hypot(first.y - last.y) <= extent * 1e-3;
2076 let same_vertex = near_in_chart
2077 && match (anchors.first(), anchors.last()) {
2078 (Some(Some(a)), Some(Some(b))) => a.distance(*b) <= tol.confusion() * 1e5,
2079 _ => false,
2080 };
2081 if first.is_equal(last, tol) || same_vertex {
2082 ring.pop();
2083 anchors.pop();
2084 }
2085 }
2086 Ok((ring, anchors, met, folds, ties))
2087}
2088
2089/// Close a ring that winds a periodic direction of its chart and found no
2090/// partner to pair with.
2091///
2092/// A ring that winds one periodic direction of a doubly-periodic surface
2093/// (a diagonal loop on a torus) ends a whole period from where it
2094/// began, and the face is the band between the chain and its own
2095/// translate one period over in the *other* periodic direction, joined
2096/// at the ends by columns that lift to one 3D circle. The translate's
2097/// anchors are the same 3D points, and the joining columns' two copies
2098/// lift identically, so the weld closes them exactly as it closes a seam.
2099/// On a cone or a sphere the ring closes against the row where the
2100/// surface collapses to a point (the apex or the pole), which every `u`
2101/// reaches: that closure costs no area, because the row has none.
2102///
2103/// A ring that has a partner (the other rim of a band) is not closed
2104/// here but paired with it, or each rim would close the whole surface on
2105/// its own and the two would cancel where they overlap.
2106fn close_wound_ring(
2107 ring: &mut Vec<Point2>,
2108 anchors: &mut Vec<Option<Point>>,
2109 geometry: &SurfaceGeometry,
2110 tol: Tolerances,
2111) {
2112 let (Some(first), Some(last)) = (ring.first().copied(), ring.last().copied()) else {
2113 return;
2114 };
2115 // A ring that winds one periodic direction of a doubly-periodic
2116 // surface: a diagonal loop on a torus. The folded walk ends a
2117 // whole period from where it began, and the face is the band
2118 // between the chain and its own translate one period over in the
2119 // *other* periodic direction, joined at the ends by columns that
2120 // lift to one 3D circle. The translate's anchors are the same 3D
2121 // points, and the joining columns' two copies lift identically,
2122 // so the weld closes them exactly as it closes a seam.
2123 use ogeom_geom::Surface as _;
2124 let ((ua, ub), (va, vb)) = geometry.domain();
2125 let du = last.x - first.x;
2126 let dv = last.y - first.y;
2127 let winds_u = geometry.is_periodic_u()
2128 && (du.abs() - (ub - ua)).abs() <= (ub - ua) * 1e-3
2129 && dv.abs() <= (vb - va).max(1.0) * 1e-3;
2130 let winds_v = geometry.is_periodic_u()
2131 && geometry.is_periodic_v()
2132 && (dv.abs() - (vb - va)).abs() <= (vb - va) * 1e-3
2133 && du.abs() <= (ub - ua).max(1.0) * 1e-3;
2134 // Where does a u-winding ring close against? On a doubly
2135 // periodic surface, its own translate one v-period over. On a
2136 // cone or sphere, the row where the surface collapses to a point
2137 // (the apex or the pole), which every u reaches: the closure
2138 // costs no area error because the row has none.
2139 let degenerate_row = |v: f64| -> bool {
2140 let (Ok(p), Ok(q), Ok(r)) = (
2141 geometry.point_at(ua, v, tol),
2142 geometry.point_at(f64::midpoint(ua, ub), v, tol),
2143 geometry.point_at(ub, v, tol),
2144 ) else {
2145 return false;
2146 };
2147 p.distance(q) <= tol.confusion() * 10.0 && p.distance(r) <= tol.confusion() * 10.0
2148 };
2149 let target_v = if winds_u && !geometry.is_periodic_v() {
2150 // The nearer degenerate row, if either end has one.
2151 let mid_v = f64::midpoint(first.y, last.y);
2152 if degenerate_row(va) && (mid_v - va).abs() <= (mid_v - vb).abs() {
2153 Some(va)
2154 } else if degenerate_row(vb) {
2155 Some(vb)
2156 } else if degenerate_row(va) {
2157 Some(va)
2158 } else {
2159 None
2160 }
2161 } else {
2162 None
2163 };
2164 let column_steps = 8;
2165 if let Some(v_apex) = target_v {
2166 // Down the seam column to the apex row, across it, and back
2167 // up: the row has no length in space, so the closure adds no
2168 // area and its lifted points weld to the one apex.
2169 let row_steps = ring.len().max(8);
2170 for k in 1..=column_steps {
2171 let f = f64::from(k) / f64::from(column_steps);
2172 ring.push(Point2::new(last.x, last.y + (v_apex - last.y) * f));
2173 anchors.push(None);
2174 }
2175 for k in 1..row_steps {
2176 #[allow(clippy::cast_precision_loss)]
2177 let f = k as f64 / row_steps as f64;
2178 ring.push(Point2::new(last.x + (first.x - last.x) * f, v_apex));
2179 anchors.push(None);
2180 }
2181 for k in 0..column_steps {
2182 let f = f64::from(column_steps - k) / f64::from(column_steps);
2183 ring.push(Point2::new(first.x, first.y + (v_apex - first.y) * f));
2184 anchors.push(None);
2185 }
2186 } else if (winds_u && geometry.is_periodic_v()) || winds_v {
2187 let shift = if winds_u {
2188 Point2::new(0.0, -(vb - va))
2189 } else {
2190 Point2::new(-(ub - ua), 0.0)
2191 };
2192 let chain: Vec<Point2> = ring.clone();
2193 let chain_anchors = anchors.clone();
2194 // Down from the chain's end to its translate's end.
2195 for k in 1..=column_steps {
2196 let f = f64::from(k) / f64::from(column_steps);
2197 ring.push(Point2::new(last.x + shift.x * f, last.y + shift.y * f));
2198 anchors.push(None);
2199 }
2200 // The translate, walked back.
2201 for (p, a) in chain.iter().rev().zip(chain_anchors.iter().rev()).skip(1) {
2202 ring.push(Point2::new(p.x + shift.x, p.y + shift.y));
2203 anchors.push(*a);
2204 }
2205 // Up from the translate's start back to the chain's start,
2206 // stopping one step short of closing.
2207 for k in 1..column_steps {
2208 let f = f64::from(column_steps - k) / f64::from(column_steps);
2209 ring.push(Point2::new(first.x + shift.x * f, first.y + shift.y * f));
2210 anchors.push(None);
2211 }
2212 }
2213}
2214
2215/// Parameters at which to sample an edge, taken from its 3D curve.
2216fn sample_parameters(
2217 model: &Model,
2218 data: &ogeom_topo::EdgeData,
2219 deflection: Deflection,
2220 tol: Tolerances,
2221) -> OgeomResult<Option<(Vec<f64>, bool)>> {
2222 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2223 return Ok(None);
2224 };
2225 let Some(geometry) = model.geometry().curve(*curve) else {
2226 ogeom_bail!(Dangling, "curve is not in this model");
2227 };
2228 let line = discretize(geometry, *range, deflection, tol)?;
2229 Ok(Some((line.parameters, line.deflection_met)))
2230}
2231
2232/// Map parameters on the 3D curve onto the pcurve's own range.
2233///
2234/// The two are parameterized over their own intervals; `same_parameter` means
2235/// they agree *proportionally*, which is what this converts.
2236fn map_to_pcurve(
2237 parameters: &[f64],
2238 data: &ogeom_topo::EdgeData,
2239 pcurve_range: (f64, f64),
2240) -> Vec<f64> {
2241 let Some(EdgeRepr::Curve3d { range, .. }) = data.curve3d() else {
2242 return parameters.to_vec();
2243 };
2244 let (ca, cb) = *range;
2245 let (pa, pb) = pcurve_range;
2246 if (cb - ca).abs() <= f64::MIN_POSITIVE {
2247 return parameters.to_vec();
2248 }
2249 parameters
2250 .iter()
2251 .map(|u| pa + (pb - pa) * (u - ca) / (cb - ca))
2252 .collect()
2253}
2254
2255/// The edge of a surface's domain, as a boundary ring.
2256///
2257/// Refined, not just the four corners. A triangulation only ever connects the
2258/// points it is given, so a boundary named by its corners alone forces long
2259/// triangles reaching right across the domain to find one: on a sphere, a
2260/// sliver from the equator to the pole. The interior refinement cannot fix that;
2261/// the missing points are on the boundary.
2262fn domain_ring(surface: &SurfaceGeometry, deflection: Deflection, tol: Tolerances) -> Vec<Point2> {
2263 let ((ua, ub), (va, vb)) = surface.domain();
2264 if ![ua, ub, va, vb].iter().all(|x| x.is_finite()) {
2265 return Vec::new();
2266 }
2267
2268 let along_u = |v: f64| {
2269 refine_direction(ua, ub, deflection.chord, |a, b| {
2270 cell_error(surface, (a, v), (b, v), deflection, tol)
2271 })
2272 };
2273 let along_v = |u: f64| {
2274 refine_direction(va, vb, deflection.chord, |a, b| {
2275 cell_error(surface, (u, a), (u, b), deflection, tol)
2276 })
2277 };
2278
2279 // Counter-clockwise around the rectangle. Each side drops its final point,
2280 // which the next side contributes: a repeated vertex would be a
2281 // zero-length boundary edge, and a constraint of zero length is not one.
2282 let (bottom, top) = (along_u(va), along_u(vb));
2283 let (left, right) = (along_v(ua), along_v(ub));
2284 let mut ring = Vec::new();
2285 ring.extend(
2286 bottom[..bottom.len() - 1]
2287 .iter()
2288 .map(|u| Point2::new(*u, va)),
2289 );
2290 ring.extend(right[..right.len() - 1].iter().map(|v| Point2::new(ub, *v)));
2291 ring.extend(top[1..].iter().rev().map(|u| Point2::new(*u, vb)));
2292 ring.extend(left[1..].iter().rev().map(|v| Point2::new(ua, *v)));
2293 ring
2294}
2295
2296/// A triangulation still in parameter space, before it is lifted onto the
2297/// surface.
2298struct PlanarMesh {
2299 /// The `(u, v)` of each vertex.
2300 parameters: Vec<(f64, f64)>,
2301 /// Triangles as indices into `parameters`.
2302 triangles: Vec<[u32; 3]>,
2303 /// The boundary alone did not triangulate to the count a boundary that
2304 /// encloses a region gives: it crosses itself somewhere.
2305 crossed: bool,
2306}
2307
2308/// Triangulate a region in parameter space, given its boundary rings.
2309///
2310/// The first ring is the outer boundary; the rest are holes.
2311fn triangulate_region(
2312 rings: &[Vec<Point2>],
2313 surface: &SurfaceGeometry,
2314 deflection: Deflection,
2315 tol: Tolerances,
2316) -> OgeomResult<PlanarMesh> {
2317 // A chart mangled enough (trims fitted through millimetres of boundary
2318 // error) can drive the triangulation library past its own asserts, and
2319 // a panic in a dependency is a crash in every consumer. The rings are
2320 // screened for what provably breaks it, and whatever still slips
2321 // through is caught at this boundary and spoken as the refusal it is:
2322 // the kernel's contract is refusal by name, never a crash on bad input.
2323 for ring in rings {
2324 let mut extent = 0.0_f64;
2325 for p in ring {
2326 if !p.x.is_finite() || !p.y.is_finite() {
2327 ogeom_bail!(
2328 NotDone,
2329 "a boundary ring carries a non-finite chart coordinate; \
2330 the face's trim does not describe a region"
2331 );
2332 }
2333 extent = extent.max(p.x.abs()).max(p.y.abs());
2334 }
2335 if extent > 1e12 {
2336 ogeom_bail!(
2337 NotDone,
2338 "a boundary ring reaches {extent:.1e} in the chart; a trim \
2339 that far out describes no face"
2340 );
2341 }
2342 }
2343 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2344 triangulate_region_inner(rings, surface, deflection, tol)
2345 })) {
2346 Ok(result) => result,
2347 Err(_) => ogeom_bail!(
2348 NotDone,
2349 "the face's boundary broke the triangulation; the chart is too \
2350 degenerate to mesh"
2351 ),
2352 }
2353}
2354
2355/// The chart stretched per axis to the surface's own metric, so that the
2356/// triangulation (Delaunay in the chart) sees distances as space does.
2357///
2358/// A fitted strip a fifth of a millimetre wide and a centimetre long may
2359/// carry `u` over a fiftieth of a unit and `v` over one: in the chart the
2360/// long way is the short way, sixty times over, and Delaunay, which
2361/// connects nearest neighbours *in the chart*, joins points along the
2362/// strip across several columns rather than to the row beside them. The
2363/// triangles it makes are slivers in space that lift folded (a flat
2364/// triangle spanning a bend the surface takes in between, its normal
2365/// pointing where none of its vertices' do), and the face shades as a
2366/// quilt of creases. Scaled by the mean tangent length each way, the
2367/// chart is the surface to first order, and Delaunay in it is Delaunay
2368/// on the surface.
2369#[derive(Debug, Clone, Copy)]
2370struct ChartScale {
2371 su: f64,
2372 sv: f64,
2373}
2374
2375impl ChartScale {
2376 /// The mean tangent length each way over the rings' extent, the longer
2377 /// normalized to one; `(1, 1)` where the surface will not say.
2378 fn of(surface: &SurfaceGeometry, rings: &[Vec<Point2>], tol: Tolerances) -> Self {
2379 let (lo, hi) = chart_extent(rings);
2380 if !(lo.x.is_finite() && hi.x.is_finite() && lo.y.is_finite() && hi.y.is_finite()) {
2381 return Self { su: 1.0, sv: 1.0 };
2382 }
2383 let (mut du, mut dv, mut n) = (0.0_f64, 0.0_f64, 0usize);
2384 for i in 0..=2 {
2385 for j in 0..=2 {
2386 let u = lo.x + (hi.x - lo.x) * (0.25 + 0.25 * f64::from(i));
2387 let v = lo.y + (hi.y - lo.y) * (0.25 + 0.25 * f64::from(j));
2388 if let Ok((a, b)) = surface.d1_at(u, v, tol) {
2389 du += a.magnitude();
2390 dv += b.magnitude();
2391 n += 1;
2392 }
2393 }
2394 }
2395 if n == 0 || !(du > 0.0 && dv > 0.0) || !du.is_finite() || !dv.is_finite() {
2396 return Self { su: 1.0, sv: 1.0 };
2397 }
2398 let longer = du.max(dv);
2399 Self {
2400 su: du / longer,
2401 sv: dv / longer,
2402 }
2403 }
2404
2405 /// A chart point into the scaled chart.
2406 fn to(self, u: f64, v: f64) -> SpadePoint<f64> {
2407 SpadePoint::new(u * self.su, v * self.sv)
2408 }
2409
2410 /// A scaled-chart point back into the chart.
2411 fn from(self, x: f64, y: f64) -> (f64, f64) {
2412 (x / self.su, y / self.sv)
2413 }
2414}
2415
2416/// The rings' bounding box in the chart.
2417fn chart_extent(rings: &[Vec<Point2>]) -> (Point2, Point2) {
2418 rings.iter().flatten().fold(
2419 (
2420 Point2::new(f64::INFINITY, f64::INFINITY),
2421 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
2422 ),
2423 |(lo, hi), p| {
2424 (
2425 Point2::new(lo.x.min(p.x), lo.y.min(p.y)),
2426 Point2::new(hi.x.max(p.x), hi.y.max(p.y)),
2427 )
2428 },
2429 )
2430}
2431
2432fn triangulate_region_inner(
2433 rings: &[Vec<Point2>],
2434 surface: &SurfaceGeometry,
2435 deflection: Deflection,
2436 tol: Tolerances,
2437) -> OgeomResult<PlanarMesh> {
2438 let mut cdt: ConstrainedDelaunayTriangulation<SpadePoint<f64>> =
2439 ConstrainedDelaunayTriangulation::new();
2440 let sub = std::time::Instant::now();
2441 let mut refused_total = 0usize;
2442
2443 // Everything the triangulation sees is in the scaled chart; the rings'
2444 // own chart points are kept by their scaled bits so a boundary vertex
2445 // comes back with exactly the parameters its anchor was keyed by.
2446 let scale = ChartScale::of(surface, rings, tol);
2447 let scaled: Vec<Vec<Point2>> = rings
2448 .iter()
2449 .map(|ring| {
2450 ring.iter()
2451 .map(|p| {
2452 let q = scale.to(p.x, p.y);
2453 Point2::new(q.x, q.y)
2454 })
2455 .collect()
2456 })
2457 .collect();
2458 let mut exact: std::collections::HashMap<(u64, u64), (f64, f64)> =
2459 std::collections::HashMap::new();
2460
2461 // The boundary edges are constraints, so the triangulation respects the
2462 // trimming rather than spanning across a hole.
2463 for (ring, chart) in scaled.iter().zip(rings) {
2464 let mut ring_handles = Vec::with_capacity(ring.len());
2465 for (p, uv) in ring.iter().zip(chart) {
2466 exact.insert((p.x.to_bits(), p.y.to_bits()), (uv.x, uv.y));
2467 // A chart coordinate can come out subnormal-tiny (the sine of
2468 // a fold angle, the residue of an exact cancellation), and the
2469 // triangulation refuses what is, for every purpose, zero.
2470 let handle = cdt
2471 .insert(mitigate_underflow(SpadePoint::new(p.x, p.y)))
2472 .map_err(|e| ogeom_core::ogeom_err!(NotDone, "boundary insertion failed: {e}"))?;
2473 ring_handles.push(handle);
2474 }
2475 let mut refused = 0usize;
2476 let mut same = 0usize;
2477 for i in 0..ring_handles.len() {
2478 let (a, b) = (ring_handles[i], ring_handles[(i + 1) % ring_handles.len()]);
2479 if a == b {
2480 same += 1;
2481 } else if cdt.can_add_constraint(a, b) {
2482 cdt.add_constraint(a, b);
2483 } else {
2484 refused += 1;
2485 if *MESH_DEBUG_REFINE {
2486 let n = ring.len();
2487 let partners: Vec<usize> = (0..n)
2488 .filter(|&j| j != i && j != (i + 1) % n && j != (i + n - 1) % n)
2489 .filter(|&j| {
2490 segments_cross(ring[i], ring[(i + 1) % n], ring[j], ring[(j + 1) % n])
2491 })
2492 .collect();
2493 let len = ring[i].distance(ring[(i + 1) % n]);
2494 eprintln!(
2495 "REFUSED segment {i} of {n} (chart length {len:.3e}) crosses {partners:?}"
2496 );
2497 for j in [(i + n - 1) % n, i, (i + 1) % n, (i + 2) % n, (i + 3) % n] {
2498 eprintln!(" ring[{j}] = ({:.12}, {:.12})", ring[j].x, ring[j].y);
2499 }
2500 }
2501 }
2502 }
2503 if *MESH_DEBUG_REFINE && (refused > 0 || same > 0) {
2504 eprintln!(
2505 "CONSTRAINTS ring of {}: {refused} refused, {same} zero-length",
2506 ring.len()
2507 );
2508 }
2509 refused_total += refused;
2510 }
2511
2512 // With the boundary in and nothing else, a boundary that encloses a
2513 // region triangulates to exactly `b + 2w - 4` triangles inside it, `b`
2514 // its distinct vertices and `w` its rings: the count any triangulation
2515 // of a polygon with holes has. One that crosses itself gives another
2516 // number: a segment refused as a constraint, a lobe wound the wrong
2517 // way. Asked here, before interior points bury the difference.
2518 let bands = RingBands::over(&scaled);
2519 // A segment the triangulation refused as a constraint crossed one
2520 // already there; that alone is the answer.
2521 let crossed = refused_total > 0
2522 || inside_by_parity(&cdt)
2523 .is_none_or(|inside| inside + 4 != cdt.num_vertices() + 2 * rings.len());
2524 if *MESH_DEBUG_REFINE && crossed {
2525 let points: usize = rings.iter().map(Vec::len).sum();
2526 eprintln!(
2527 "PARITY inside {:?} vertices {} points {points} rings {} inner faces {}",
2528 inside_by_parity(&cdt),
2529 cdt.num_vertices(),
2530 rings.len(),
2531 cdt.num_inner_faces()
2532 );
2533 }
2534
2535 // Interior points where the surface bends away from the flat triangle. A
2536 // planar face needs none, which is why this is driven by measured
2537 // deflection rather than by a fixed grid.
2538 let boundary_ms = sub.elapsed().as_secs_f64() * 1e3;
2539 let sub = std::time::Instant::now();
2540 add_interior_points(&mut cdt, rings, surface, deflection, scale, tol)?;
2541 let interior_ms = sub.elapsed().as_secs_f64() * 1e3;
2542 let interior_points = cdt.num_vertices();
2543 let sub = std::time::Instant::now();
2544 let mut rounds_run = 0usize;
2545
2546 // The scale a degenerate chart triangle is measured against: the
2547 // region's own span, the longer way. Its *position* is not its size:
2548 // a face on a cylinder whose axis point sits half a metre away has
2549 // `v` near −500 000 and a span of twenty, and a scale taken from where
2550 // the ring sits rather than how far it reaches would call every honest
2551 // cell a hair.
2552 let (lo, hi) = chart_extent(&scaled);
2553 let extent = (hi.x - lo.x).max(hi.y - lo.y);
2554 let degenerate_area = extent.max(1.0).powi(2) * 1e-12;
2555
2556 // The grid rows guarantee the deflection along their own lines, but a
2557 // hole in the face punches a gap through a row, and where the surface is
2558 // flat in one direction (a cylinder along its axis) there may be no
2559 // other row for the mesher to reach. The band around the hole then fans
2560 // from the rim to the far side of the gap, in triangles that sag through
2561 // the solid by far more than the deflection while every one of their
2562 // vertices sits exactly on the surface. The boolean caught this as a
2563 // fused solid whose faces all had the right area and the wrong volume.
2564 //
2565 // The repair measures the truth: any kept triangle whose midpoints sag
2566 // beyond the chord gets its centre inserted, and the loop runs until the
2567 // mesh is honest or the cap says the surface is being unreasonable.
2568 // The rings do not change while the mesh is refined, so the containment
2569 // test they answer is indexed once and reused by every round below and by
2570 // the output pass.
2571 if !matches!(surface.kind(), ogeom_geom::SurfaceKind::Plane) {
2572 for _ in 0..REFINEMENT_ROUNDS {
2573 rounds_run += 1;
2574 let before = cdt.num_vertices();
2575 let mut worst: Vec<SpadePoint<f64>> = Vec::new();
2576 for triangle in cdt.inner_faces() {
2577 let vertices = triangle.vertices();
2578 let centre = triangle.center();
2579 let at = Point2::new(centre.x, centre.y);
2580 if !bands.holds(at) {
2581 continue;
2582 }
2583 let corners: [(f64, f64); 3] = [
2584 (vertices[0].position().x, vertices[0].position().y),
2585 (vertices[1].position().x, vertices[1].position().y),
2586 (vertices[2].position().x, vertices[2].position().y),
2587 ];
2588 // A chart-degenerate hair is dropped from the mesh, not
2589 // refined: its 3D chord can sag enormously, and feeding its
2590 // centre back in only breeds more hairs along the same line.
2591 let area = ((corners[1].0 - corners[0].0) * (corners[2].1 - corners[0].1)
2592 - (corners[1].1 - corners[0].1) * (corners[2].0 - corners[0].0))
2593 .abs()
2594 / 2.0;
2595 if area < degenerate_area {
2596 continue;
2597 }
2598 // The grid already bounds sag along rows and columns, and a
2599 // grid triangle's diagonal spanning one cell each way may
2600 // legitimately sag up to the sum (twice the chord), which
2601 // was the guarantee before this loop existed. The threshold
2602 // sits clear above that band so the repair fires only on the
2603 // fan triangles it exists for, which sag through a hole's
2604 // gap by tens of chords, and an honest grid (including a
2605 // perfectly symmetric one, whose mesh must stay symmetric)
2606 // is left untouched.
2607 let sagged = (0..3).any(|i| {
2608 let (a, b) = (corners[i], corners[(i + 1) % 3]);
2609 sag_between(surface, scale.from(a.0, a.1), scale.from(b.0, b.1), tol)
2610 > deflection.chord * 3.0
2611 });
2612 if sagged {
2613 worst.push(SpadePoint::new(centre.x, centre.y));
2614 }
2615 }
2616 if worst.is_empty() {
2617 break;
2618 }
2619 let mut inserted = 0usize;
2620 for point in worst {
2621 // A centre that lands on a vertex already there is not a new
2622 // point. A sliver whose apex sits on its own base (three
2623 // grid points on a diagonal, the middle one a rounding off
2624 // the line) has its centre at that apex to the last bits,
2625 // and inserting it breeds a hair a few ulps wide, whose
2626 // centre is the same point again: round after round, a
2627 // stack of hairs the degenerate filter then drops, and a
2628 // hole in the face where they were.
2629 if lands_on_the_mesh(&cdt, point, extent * 1e-9, (1.0, 1.0)) {
2630 continue;
2631 }
2632 cdt.insert(mitigate_underflow(point)).map_err(|e| {
2633 ogeom_core::ogeom_err!(NotDone, "refinement insertion failed: {e}")
2634 })?;
2635 inserted += 1;
2636 }
2637 if inserted == 0 {
2638 // Everything that sagged was a hair on a vertex; another
2639 // round would find the same hairs.
2640 break;
2641 }
2642 if *MESH_DEBUG_REFINE {
2643 eprintln!(
2644 "ROUND {rounds_run}: +{} vertices",
2645 cdt.num_vertices() - before
2646 );
2647 }
2648 }
2649 }
2650
2651 let refine_ms = sub.elapsed().as_secs_f64() * 1e3;
2652 if *MESH_DEBUG_REFINE && boundary_ms + interior_ms + refine_ms > 50.0 {
2653 eprintln!(
2654 "SUB boundary {boundary_ms:.0}ms interior {interior_ms:.0}ms ({interior_points} verts) refine {refine_ms:.0}ms ({rounds_run} rounds, {} verts)",
2655 cdt.num_vertices()
2656 );
2657 }
2658 let mut parameters = Vec::new();
2659 let mut index_of = std::collections::HashMap::new();
2660 for (i, vertex) in cdt.vertices().enumerate() {
2661 let p = vertex.position();
2662 index_of.insert(vertex.fix(), i);
2663 parameters.push(
2664 exact
2665 .get(&(p.x.to_bits(), p.y.to_bits()))
2666 .copied()
2667 .unwrap_or_else(|| scale.from(p.x, p.y)),
2668 );
2669 }
2670
2671 let mut triangles = Vec::new();
2672 let (mut dbg_total, mut dbg_outside, mut dbg_degenerate) = (0usize, 0usize, 0usize);
2673 for triangle in cdt.inner_faces() {
2674 dbg_total += 1;
2675 let vertices = triangle.vertices();
2676 let centre = triangle.center();
2677 // A constrained Delaunay covers the convex hull of its input, so
2678 // triangles outside the trimmed region (across a concavity, or inside
2679 // a hole) have to be discarded. Winding tells them apart.
2680 if !bands.holds(Point2::new(centre.x, centre.y)) {
2681 dbg_outside += 1;
2682 continue;
2683 }
2684 // A boundary run whose points differ by last-bit noise (a chart row
2685 // whose corner came off a different pcurve than its interior) lets
2686 // the triangulation weave a hair of a triangle along it: chart area
2687 // measured in ulps, a centroid *on* the boundary that even-odd
2688 // counting places wherever rounding falls, and a lifted sliver that
2689 // spans the run in one spurious stroke. It bounds nothing; drop it.
2690 let area = {
2691 let [a, b, c] = [
2692 vertices[0].position(),
2693 vertices[1].position(),
2694 vertices[2].position(),
2695 ];
2696 ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs() / 2.0
2697 };
2698 if area < degenerate_area {
2699 dbg_degenerate += 1;
2700 continue;
2701 }
2702 #[allow(clippy::cast_possible_truncation)]
2703 let indices = [
2704 index_of[&vertices[0].fix()] as u32,
2705 index_of[&vertices[1].fix()] as u32,
2706 index_of[&vertices[2].fix()] as u32,
2707 ];
2708 triangles.push(indices);
2709 }
2710
2711 if *MESH_DEBUG_REFINE {
2712 eprintln!(
2713 "FILTER {dbg_total} triangles: {dbg_outside} outside, {dbg_degenerate} degenerate (area < {degenerate_area:.3e}), {} kept",
2714 triangles.len()
2715 );
2716 }
2717 // No triangles is not refused here: a boundary drawn coarsely enough
2718 // to cross itself can enclose nothing at all (an annulus narrower
2719 // than the sag of its rims' polygons, two arcs a hair apart), and the
2720 // caller's answer to a crossing is to draw the edges finer and ask
2721 // again. Empty counts as short; only a face still empty after that is
2722 // refused, by [`triangulate_with`].
2723 Ok(PlanarMesh {
2724 parameters,
2725 triangles,
2726 crossed,
2727 })
2728}
2729
2730/// Add interior points wherever the surface deviates from flat by more than the
2731/// deflection allows.
2732///
2733/// The two parameter directions are refined *independently*, and that is not an
2734/// optimization; it is the difference between converging and not. A uniform
2735/// grid on a sphere puts as many meridians through the pole as through the
2736/// equator, so the triangles there become arbitrarily thin slivers in space.
2737/// The summed area of such a mesh does not approach the sphere's; it grows
2738/// without bound as the grid tightens. (Schwarz's lantern is the standard
2739/// example: an inscribed polyhedron whose area diverges under refinement.)
2740///
2741/// Refining each direction by its own measured sag fixes it at the source. Near
2742/// a pole the circle of latitude has almost no radius, so a chord right across
2743/// it sags by almost nothing and the direction stops subdividing after one or
2744/// two steps, while the meridian direction, whose curvature does not change,
2745/// keeps refining. The mesh degenerates into a fan, which is the right shape.
2746fn add_interior_points(
2747 cdt: &mut ConstrainedDelaunayTriangulation<SpadePoint<f64>>,
2748 rings: &[Vec<Point2>],
2749 surface: &SurfaceGeometry,
2750 deflection: Deflection,
2751 scale: ChartScale,
2752 tol: Tolerances,
2753) -> OgeomResult<()> {
2754 // A plane is flat everywhere; sampling it would add points that buy nothing.
2755 if matches!(surface.kind(), ogeom_geom::SurfaceKind::Plane) {
2756 return Ok(());
2757 }
2758
2759 let bound = rings
2760 .iter()
2761 .flatten()
2762 .fold(ogeom_math::Aabb::EMPTY, |acc, p| {
2763 acc.with_point(Point::new(p.x, p.y, 0.0))
2764 });
2765 let (Some(low), Some(high)) = (bound.low(), bound.high()) else {
2766 return Ok(());
2767 };
2768 // Within this of a boundary vertex or segment is on it: a hair's width
2769 // at the scaled chart's scale, the same reach the repair pass keeps.
2770 let reach = ((high.x - low.x) * scale.su)
2771 .max((high.y - low.y) * scale.sv)
2772 .max(1.0)
2773 * 1e-9;
2774
2775 // The v resolution has to hold everywhere the region reaches, so its sag is
2776 // the worst over a spread of u probes rather than the sag along one line.
2777 // A surface of revolution is the same at every u and a lofted one is not.
2778 #[allow(clippy::cast_precision_loss)]
2779 let probes: Vec<f64> = (0..=U_PROBES)
2780 .map(|i| low.x + (high.x - low.x) * i as f64 / U_PROBES as f64)
2781 .collect();
2782 let rows = refine_direction(low.y, high.y, deflection.chord, |a, b| {
2783 probes
2784 .iter()
2785 .map(|u| cell_error(surface, (*u, a), (*u, b), deflection, tol))
2786 .fold(0.0_f64, f64::max)
2787 });
2788
2789 // Sag alone leaves a cylinder one row: it is straight along its axis, so
2790 // nothing along `v` ever sags. But the triangulation is Delaunay in the
2791 // chart, and a bore four hundred millimetres long with one row in the
2792 // middle hands it two-hundred-millimetre spans from each rim to that
2793 // row. Delaunay bridges those however it likes, and the repair below
2794 // fires only at three chords; a triangle a quarter turn wide on a two
2795 // millimetre bore sags less than that, so it stayed, and the bore drew
2796 // as a square between its holes. Cells are held to a bounded aspect
2797 // instead: rows close enough, measured in space through the surface,
2798 // that no triangle between two rows can reach across more than a few
2799 // columns. Rows are added, never removed, and spread evenly, so a grid
2800 // that was symmetric stays symmetric. The same the other way round.
2801 // How far apart two chart points are in space, where the surface says.
2802 let span = |p: (f64, f64), q: (f64, f64)| -> f64 {
2803 use ogeom_geom::Surface as _;
2804 match (
2805 surface.point_at(p.0, p.1, tol),
2806 surface.point_at(q.0, q.1, tol),
2807 ) {
2808 (Ok(a), Ok(b)) => a.distance(b),
2809 _ => 0.0,
2810 }
2811 };
2812 let rows = spread_to_aspect(
2813 rows,
2814 low.x,
2815 high.x,
2816 |v| {
2817 let columns = refine_direction(low.x, high.x, deflection.chord, |a, b| {
2818 cell_error(surface, (a, v), (b, v), deflection, tol)
2819 });
2820 columns.len()
2821 },
2822 |a, b| {
2823 probes
2824 .iter()
2825 .map(|&u| span((u, a), (u, b)))
2826 .fold(0.0_f64, f64::max)
2827 },
2828 |a, b, v| span((a, v), (b, v)),
2829 );
2830
2831 if *MESH_DEBUG_REFINE {
2832 let v = f64::midpoint(low.y, high.y);
2833 let columns = refine_direction(low.x, high.x, deflection.chord, |a, b| {
2834 cell_error(surface, (a, v), (b, v), deflection, tol)
2835 });
2836 eprintln!(
2837 "GRID u [{:.3},{:.3}] v [{:.3},{:.3}]: {} rows after aspect, {} columns at the middle row, chord {}",
2838 low.x,
2839 high.x,
2840 low.y,
2841 high.y,
2842 rows.len(),
2843 columns.len(),
2844 deflection.chord
2845 );
2846 }
2847 for (row, &v) in rows
2848 .iter()
2849 .enumerate()
2850 .take(rows.len().saturating_sub(1))
2851 .skip(1)
2852 {
2853 // The row gap either side of this row, the finer of the two: the
2854 // chart scale a keep-out band is measured against along `v`.
2855 let dv = (v - rows[row - 1]).abs().min((rows[row + 1] - v).abs());
2856 // Each row gets its own u resolution, measured at that row.
2857 let columns = refine_direction(low.x, high.x, deflection.chord, |a, b| {
2858 cell_error(surface, (a, v), (b, v), deflection, tol)
2859 });
2860 // The same the other way round: a surface straight along `u` gets
2861 // two columns from sag, and a row a hundred millimetres wide would
2862 // bridge across the rows as badly as the bore bridged its columns.
2863 let columns = spread_to_aspect(
2864 columns,
2865 low.y,
2866 high.y,
2867 |_| rows.len(),
2868 |a, b| span((a, v), (b, v)),
2869 |a, b, u| span((u, a), (u, b)),
2870 );
2871 for (column, &u) in columns
2872 .iter()
2873 .enumerate()
2874 .take(columns.len().saturating_sub(1))
2875 .skip(1)
2876 {
2877 // Interior points only: the boundary is already constrained, and a
2878 // point landing just off a constraint would split it.
2879 if !inside_region(rings, Point2::new(u, v)) {
2880 continue;
2881 }
2882 // Inside, and not *on* the boundary: a grid point can fall
2883 // exactly on a ring segment that runs diagonally across the
2884 // chart (the midpoint of two grid corners the ring happens to
2885 // join), and even-odd counting calls it inside. Inserted, it
2886 // splits that constraint on this face alone, and the face
2887 // across the edge is drawn to the unsplit segment.
2888 let point = scale.to(u, v);
2889 if lands_on_the_mesh(cdt, point, reach, (1.0, 1.0)) {
2890 continue;
2891 }
2892 // Nor *near* it, measured in cells. A grid point a sliver's
2893 // width from a boundary chord makes a triangle with that
2894 // chord's two ends that is thin in the chart and, lifted, is
2895 // not thin at all: the chord cuts across the curvature by its
2896 // sag and the point sits on the surface, so the triangle
2897 // stands off the surface as a fin whose normal is tangent to
2898 // it and whose sign is whichever way the sliver leaned. A face
2899 // shades with a crease along every such chord. The point is
2900 // left out and the boundary's own row of triangles reaches
2901 // to the next grid line instead.
2902 let du = (u - columns[column - 1])
2903 .abs()
2904 .min((columns[column + 1] - u).abs());
2905 if du > 0.0
2906 && dv > 0.0
2907 && lands_on_the_mesh(
2908 cdt,
2909 point,
2910 KEEP_OUT,
2911 (1.0 / (du * scale.su), 1.0 / (dv * scale.sv)),
2912 )
2913 {
2914 continue;
2915 }
2916 cdt.insert(mitigate_underflow(point))
2917 .map_err(|e| ogeom_core::ogeom_err!(NotDone, "interior insertion failed: {e}"))?;
2918 }
2919 }
2920 Ok(())
2921}
2922
2923/// Whether segments `a..b` and `c..d` cross properly: at a point interior
2924/// to both, neither touching the other's end.
2925fn segments_cross(a: Point2, b: Point2, c: Point2, d: Point2) -> bool {
2926 let orient =
2927 |p: Point2, q: Point2, r: Point2| (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
2928 let (o1, o2) = (orient(a, b, c), orient(a, b, d));
2929 let (o3, o4) = (orient(c, d, a), orient(c, d, b));
2930 o1 != 0.0
2931 && o2 != 0.0
2932 && o3 != 0.0
2933 && o4 != 0.0
2934 && (o1 > 0.0) != (o2 > 0.0)
2935 && (o3 > 0.0) != (o4 > 0.0)
2936}
2937
2938/// How many of the triangulation's faces lie inside its constraints, told
2939/// by parity rather than by geometry.
2940///
2941/// Walking from a face on the convex hull, which is outside, every
2942/// constraint edge crossed flips inside for outside. Asked of the
2943/// triangulation of a boundary and nothing else, this is exact where the
2944/// even-odd test of a triangle's centre is not: a sliver face triangulates
2945/// to hairs whose centres sit on the boundary to the last bit, and which
2946/// side rounding puts them is a coin toss. `None` when the walk reaches a
2947/// face both ways with different answers: the constraints do not enclose
2948/// consistently, which is a crossing by another name.
2949fn inside_by_parity(cdt: &ConstrainedDelaunayTriangulation<SpadePoint<f64>>) -> Option<usize> {
2950 use std::collections::HashMap;
2951 let mut parity: HashMap<spade::handles::FixedFaceHandle<spade::handles::InnerTag>, bool> =
2952 HashMap::with_capacity(cdt.num_inner_faces());
2953 let mut queue = Vec::new();
2954 for hull in cdt.convex_hull() {
2955 // The hull edge's far side is the outer face; its near side is a
2956 // face of the triangulation, outside unless the hull edge itself
2957 // is a boundary.
2958 let Some(face) = hull.rev().face().as_inner() else {
2959 continue;
2960 };
2961 let inside = hull.is_constraint_edge();
2962 match parity.get(&face.fix()) {
2963 Some(&known) if known != inside => return None,
2964 Some(_) => {}
2965 None => {
2966 parity.insert(face.fix(), inside);
2967 queue.push((face.fix(), inside));
2968 }
2969 }
2970 }
2971 while let Some((face, inside)) = queue.pop() {
2972 for edge in cdt.face(face).adjacent_edges() {
2973 let Some(next) = edge.rev().face().as_inner() else {
2974 continue;
2975 };
2976 let next_inside = inside != edge.is_constraint_edge();
2977 match parity.get(&next.fix()) {
2978 Some(&known) if known != next_inside => return None,
2979 Some(_) => {}
2980 None => {
2981 parity.insert(next.fix(), next_inside);
2982 queue.push((next.fix(), next_inside));
2983 }
2984 }
2985 }
2986 }
2987 Some(parity.values().filter(|&&inside| inside).count())
2988}
2989
2990/// Whether `point` sits within `reach` of a vertex the triangulation has,
2991/// or of one of its constraint edges.
2992///
2993/// Asked of whatever the point lands on (a vertex, an edge's two ends, a
2994/// face's three corners and whichever of its sides are constraints),
2995/// which is where anything that close must be. A point on a vertex is not
2996/// a new point; a point on a constraint would split it, and a boundary
2997/// split on one face only is a crack against the face across it.
2998fn lands_on_the_mesh(
2999 cdt: &ConstrainedDelaunayTriangulation<SpadePoint<f64>>,
3000 point: SpadePoint<f64>,
3001 reach: f64,
3002 scale: (f64, f64),
3003) -> bool {
3004 use spade::PositionInTriangulation as At;
3005 // Distances in a chart scaled per axis: `scale` is one over the local
3006 // grid step each way, so `reach` reads in cells, whatever the chart's
3007 // own units: one face's `u` runs over a fiftieth of its `v`.
3008 let scaled = |p: SpadePoint<f64>| ((p.x - point.x) * scale.0, (p.y - point.y) * scale.1);
3009 let near = |v: SpadePoint<f64>| {
3010 let (x, y) = scaled(v);
3011 x.hypot(y) <= reach
3012 };
3013 let along = |a: SpadePoint<f64>, b: SpadePoint<f64>| {
3014 // Distance to the segment `a..b`, the point at the origin.
3015 let (ax, ay) = scaled(a);
3016 let (bx, by) = scaled(b);
3017 let (dx, dy) = (bx - ax, by - ay);
3018 let len2 = dx * dx + dy * dy;
3019 let t = if len2 > 0.0 {
3020 ((-ax * dx - ay * dy) / len2).clamp(0.0, 1.0)
3021 } else {
3022 0.0
3023 };
3024 (ax + t * dx).hypot(ay + t * dy) <= reach
3025 };
3026 match cdt.locate(point) {
3027 At::OnVertex(_) => true,
3028 At::OnEdge(edge) => {
3029 let edge = cdt.directed_edge(edge);
3030 edge.is_constraint_edge() || edge.vertices().iter().any(|v| near(v.position()))
3031 }
3032 At::OnFace(face) => {
3033 let face = cdt.face(face);
3034 face.vertices().iter().any(|v| near(v.position()))
3035 || face.adjacent_edges().iter().any(|e| {
3036 e.is_constraint_edge() && along(e.from().position(), e.to().position())
3037 })
3038 }
3039 At::OutsideOfConvexHull(_) | At::NoTriangulation => false,
3040 }
3041}
3042
3043/// How close to the boundary, in grid cells, an interior point may sit.
3044///
3045/// Closer than this the triangle between the point and a boundary chord
3046/// is a sliver in the chart and a fin in space; at this and beyond the
3047/// boundary's own row of triangles is at least this tall against the
3048/// chord, and lifts as a facet on the surface rather than off it.
3049const KEEP_OUT: f64 = 0.35;
3050
3051/// How many column widths a grid cell may be tall before rows are added.
3052///
3053/// Delaunay in the chart connects nearest neighbours in the chart; held to
3054/// this aspect, a cell's nearest neighbours across a row gap are the same
3055/// columns, not columns several away, and the triangles between rows stay
3056/// as narrow as the columns are.
3057const CELL_ASPECT: f64 = 6.0;
3058
3059/// Grid lines in one direction spread so that no cell is longer, in
3060/// space, than [`CELL_ASPECT`] times its width the other way: extra
3061/// lines added evenly between the ones sag chose.
3062///
3063/// Written for rows against columns and used both ways round. `lines`
3064/// are the parameters sag chose in this direction; `lo..hi` is the chart
3065/// range the other way; `crossings_at(t)` counts the lines the other way
3066/// at parameter `t` of this one; `length(a, b)` is the extent in space
3067/// between two lines of this direction; `width(a, b, t)` is the extent in
3068/// space between two parameters of the other direction, along this one's
3069/// line `t`.
3070fn spread_to_aspect(
3071 lines: Vec<f64>,
3072 lo: f64,
3073 hi: f64,
3074 crossings_at: impl Fn(f64) -> usize,
3075 length: impl Fn(f64, f64) -> f64,
3076 width: impl Fn(f64, f64, f64) -> f64,
3077) -> Vec<f64> {
3078 if lines.len() < 2 {
3079 return lines;
3080 }
3081 let mid = f64::midpoint(lines[0], lines[lines.len() - 1]);
3082 let crossings = crossings_at(mid);
3083 if crossings < 4 {
3084 // Flat the other way as well: a plane in all but name, and nothing
3085 // to hold an aspect against.
3086 return lines;
3087 }
3088 // One cell's width, not the whole range's: across a closed direction
3089 // the whole range comes back to its own start and measures nothing.
3090 #[allow(clippy::cast_precision_loss)]
3091 let step = (hi - lo) / (crossings - 1) as f64;
3092 let cell = width(lo, lo + step, mid);
3093 if cell.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
3094 return lines;
3095 }
3096 let mut out = Vec::with_capacity(lines.len());
3097 for pair in lines.windows(2) {
3098 let (a, b) = (pair[0], pair[1]);
3099 out.push(a);
3100 let tall = length(a, b);
3101 let pieces = (tall / (CELL_ASPECT * cell)).ceil();
3102 if pieces.is_finite() && pieces > 1.0 {
3103 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3104 let n = (pieces as usize).min(MAX_DIRECTION_STEPS);
3105 #[allow(clippy::cast_precision_loss)]
3106 for i in 1..n {
3107 out.push(a + (b - a) * i as f64 / n as f64);
3108 }
3109 }
3110 }
3111 out.push(lines[lines.len() - 1]);
3112 out
3113}
3114
3115/// How many places across the domain the v resolution is measured at.
3116const U_PROBES: usize = 8;
3117
3118/// How many rounds of sag-driven refinement a region may take.
3119///
3120/// Each round halves the worst sag roughly; a surface not honest after this
3121/// many is degenerate, and the cap makes that a coarse mesh rather than an
3122/// exhausted allocator.
3123const REFINEMENT_ROUNDS: usize = 12;
3124
3125/// The most subdivisions one parameter direction may take.
3126///
3127/// A surface that has not converged by 512 has a singularity, not a resolution
3128/// problem, and the ceiling is what makes that a coarse mesh rather than an
3129/// exhausted allocator.
3130const MAX_DIRECTION_STEPS: usize = 512;
3131
3132/// Subdivide `[lo, hi]` until no sub-interval sags further than `chord`.
3133///
3134/// The same adaptive bisection [`discretize`] uses on a curve, applied to a
3135/// line through parameter space. Returns the parameters in increasing order,
3136/// endpoints included.
3137fn refine_direction<F: Fn(f64, f64) -> f64>(lo: f64, hi: f64, chord: f64, sag: F) -> Vec<f64> {
3138 let mut values = vec![lo, f64::midpoint(lo, hi), hi];
3139 // A cursor rather than a rescan. Splitting an interval cannot change
3140 // whether an *earlier* one sags (the earlier one's endpoints do not move),
3141 // so restarting the search at zero re-measures intervals already known
3142 // to be good, and re-measuring is what costs: each measurement here is
3143 // several `sag_between` calls and each of those is three surface
3144 // evaluations. Reaching n points that way costs on the order of n²
3145 // measurements; walking forward costs n, and splits in the same
3146 // left-to-right order, so the values come out identical, including where
3147 // the step cap truncates them.
3148 let mut i = 0;
3149 while i + 1 < values.len() && values.len() < MAX_DIRECTION_STEPS {
3150 if sag(values[i], values[i + 1]) <= chord {
3151 i += 1;
3152 continue;
3153 }
3154 let mid = f64::midpoint(values[i], values[i + 1]);
3155 // A split that does not divide the interval means the parameters have
3156 // reached the resolution of f64, and refining further would loop.
3157 if mid <= values[i] || mid >= values[i + 1] {
3158 break;
3159 }
3160 values.insert(i + 1, mid);
3161 }
3162 values
3163}
3164
3165/// How far the surface departs from the chord joining two parameter points.
3166///
3167/// Measured in space, which is the only place the number means anything: the
3168/// same step in `u` covers a metre at a sphere's equator and a millimetre near
3169/// its pole.
3170/// How far a grid cell's edge is from honest, as a sag: the chord sag
3171/// itself, or the normal's turn across it scaled so that a turn of the
3172/// angular deflection weighs the same as a sag of the chord, whichever
3173/// is worse.
3174///
3175/// The chord alone is what the boundary's edges are *not* drawn to: a
3176/// curve is discretized to both deflections, so a bore's rims come out
3177/// round at the angular limit while columns held to the chord alone come
3178/// out a polygon of far fewer sides, and the bore changes shape a chord's
3179/// length in from each rim. The interior is held to the same two limits
3180/// the boundary is.
3181fn cell_error(
3182 surface: &SurfaceGeometry,
3183 from: (f64, f64),
3184 to: (f64, f64),
3185 deflection: Deflection,
3186 tol: Tolerances,
3187) -> f64 {
3188 let sag = sag_between(surface, from, to, tol);
3189 let turn = match (
3190 surface.normal_at(from.0, from.1, tol),
3191 surface.normal_at(to.0, to.1, tol),
3192 ) {
3193 (Ok(a), Ok(b)) => a.angle(b),
3194 // A pole or an apex has no normal to compare; the sag still governs.
3195 _ => 0.0,
3196 };
3197 sag.max(turn / deflection.angular * deflection.chord)
3198}
3199
3200fn sag_between(
3201 surface: &SurfaceGeometry,
3202 from: (f64, f64),
3203 to: (f64, f64),
3204 tol: Tolerances,
3205) -> f64 {
3206 let mid = (f64::midpoint(from.0, to.0), f64::midpoint(from.1, to.1));
3207 let (Ok(a), Ok(b), Ok(m)) = (
3208 surface.point_at(from.0, from.1, tol),
3209 surface.point_at(to.0, to.1, tol),
3210 surface.point_at(mid.0, mid.1, tol),
3211 ) else {
3212 // Off the surface's domain; nothing to refine towards.
3213 return 0.0;
3214 };
3215 ogeom_math::Axis::through(a, b, tol).map_or_else(|_| a.distance(m), |axis| axis.distance_to(m))
3216}
3217
3218/// Whether a point is inside the region the rings bound.
3219fn inside_region(rings: &[Vec<Point2>], p: Point2) -> bool {
3220 inside_boundary_with::<Exact>(rings, p)
3221}
3222
3223/// Ray-crossing count for one ring, decided by orientation rather than by
3224/// arithmetic.
3225///
3226/// A horizontal ray in `+u`. The half-open `y` comparison counts a vertex lying
3227/// exactly on the ray once rather than twice or not at all; which side of the
3228/// edge the point falls on is then an `orient2d` sign.
3229///
3230/// Deliberately *not* "solve for where the edge crosses the ray, then compare".
3231/// That form divides by the edge's `y` extent, which is near zero for a nearly
3232/// horizontal edge, and subtracts two nearly equal numbers to compare, so for a
3233/// point close to the boundary it can answer either way. Here there is no
3234/// division and the comparison is a determinant's sign, which an exact predicate
3235/// gets right at any separation.
3236fn crosses_odd_times<P: Predicates>(ring: &[Point2], p: Point2) -> bool {
3237 let mut inside = false;
3238 let n = ring.len();
3239 let at = |q: Point2| [q.x, q.y];
3240 for i in 0..n {
3241 let (a, b) = (ring[i], ring[(i + 1) % n]);
3242 if (a.y > p.y) == (b.y > p.y) {
3243 continue;
3244 }
3245 // The edge crosses the ray's line. Whether it crosses the ray *itself*
3246 // (to the right of `p`) is which side of the directed edge `p` is on,
3247 // read the right way round for the edge's direction in `y`.
3248 let side = P::orient2d(at(a), at(b), [p.x, p.y]);
3249 let rightwards = if b.y > a.y {
3250 side == ogeom_core::Sign::Positive
3251 } else {
3252 side == ogeom_core::Sign::Negative
3253 };
3254 if rightwards {
3255 inside = !inside;
3256 }
3257 }
3258 inside
3259}
3260
3261/// Whether the mesh debug dump is on, read once.
3262///
3263/// `env::var` takes a process-wide lock and allocates its answer, and this was
3264/// asked once per face; on an imported assembly, once per face of every part.
3265/// Whether to report, per shape, how many faces were drawn again finer.
3266static MESH_DEBUG_REFINE: std::sync::LazyLock<bool> =
3267 std::sync::LazyLock::new(|| std::env::var("OGEOM_MESH_DEBUG_REFINE").is_ok());
3268
3269static MESH_DEBUG: std::sync::LazyLock<bool> =
3270 std::sync::LazyLock::new(|| std::env::var("OGEOM_MESH_DEBUG").is_ok());
3271
3272/// The ring edges that can cross a horizontal ray, bucketed by height.
3273///
3274/// [`crosses_odd_times`] walks every edge of every ring for each point it is
3275/// asked about. That is fine for a handful of queries and ruinous for the
3276/// refinement loop, which asks once per triangle per round while the rings
3277/// themselves never change: a face with 544 boundary points and 41 000
3278/// triangles pays a quarter of a billion edge visits, nearly all on edges
3279/// nowhere near the point.
3280///
3281/// An edge can only straddle a ray at height `y` if `y` lies within the edge's
3282/// own `y` span, so bucketing edges by that span and querying one bucket tests
3283/// a conservative superset of the edges that could contribute. **The answer is
3284/// therefore identical**: the same straddle test and the same exact predicate
3285/// decide each candidate; the index only declines to visit edges that could
3286/// not have counted.
3287///
3288/// Parity is taken over all rings at once, which is what
3289/// [`inside_boundary_with`] computes as an exclusive-or of per-ring parities:
3290/// the two agree because the parity of the total crossing count is the sum of
3291/// the rings' parities.
3292struct RingBands {
3293 /// Every ring's edges, flattened.
3294 edges: Vec<(Point2, Point2)>,
3295 /// Edge indices per band, low `y` first.
3296 bands: Vec<Vec<u32>>,
3297 low: f64,
3298 high: f64,
3299 /// Band height. Zero when every point shares one `y`, which leaves a
3300 /// single band holding everything.
3301 step: f64,
3302}
3303
3304impl RingBands {
3305 /// Index the rings. Cheap enough to build per face and paid back by the
3306 /// first few hundred queries.
3307 fn over(rings: &[Vec<Point2>]) -> Self {
3308 let mut edges = Vec::new();
3309 for ring in rings {
3310 for i in 0..ring.len() {
3311 edges.push((ring[i], ring[(i + 1) % ring.len()]));
3312 }
3313 }
3314 let (mut low, mut high) = (f64::INFINITY, f64::NEG_INFINITY);
3315 for (a, b) in &edges {
3316 low = low.min(a.y).min(b.y);
3317 high = high.max(a.y).max(b.y);
3318 }
3319 if edges.is_empty() || !low.is_finite() || !high.is_finite() {
3320 return Self {
3321 edges,
3322 bands: Vec::new(),
3323 low: 0.0,
3324 high: 0.0,
3325 step: 0.0,
3326 };
3327 }
3328 // About four edges to a band: enough to keep the per-query walk short
3329 // without spreading a long edge across a table of mostly empty bands.
3330 let count = edges.len().div_ceil(4).clamp(1, 4096);
3331 #[allow(
3332 clippy::cast_precision_loss,
3333 reason = "a band count, far below the integers f64 represents exactly"
3334 )]
3335 let step = (high - low) / count as f64;
3336 let mut bands: Vec<Vec<u32>> = vec![Vec::new(); count];
3337 for (i, (a, b)) in edges.iter().enumerate() {
3338 let (lo, hi) = (a.y.min(b.y), a.y.max(b.y));
3339 let first = Self::band_of(lo, low, step, count);
3340 let last = Self::band_of(hi, low, step, count);
3341 for band in &mut bands[first..=last] {
3342 #[allow(
3343 clippy::cast_possible_truncation,
3344 reason = "an edge index, bounded by the ring lengths"
3345 )]
3346 band.push(i as u32);
3347 }
3348 }
3349 Self {
3350 edges,
3351 bands,
3352 low,
3353 high,
3354 step,
3355 }
3356 }
3357
3358 /// Which band a height falls in, clamped to the table.
3359 fn band_of(y: f64, low: f64, step: f64, count: usize) -> usize {
3360 if step <= 0.0 {
3361 return 0;
3362 }
3363 let raw = (y - low) / step;
3364 if raw <= 0.0 {
3365 return 0;
3366 }
3367 #[allow(
3368 clippy::cast_possible_truncation,
3369 clippy::cast_sign_loss,
3370 reason = "clamped to the band count on the next line"
3371 )]
3372 let index = raw as usize;
3373 index.min(count - 1)
3374 }
3375
3376 /// Whether the point lies inside the region the rings bound.
3377 fn holds(&self, p: Point2) -> bool {
3378 if self.bands.is_empty() || p.y < self.low || p.y > self.high {
3379 return false;
3380 }
3381 let band = Self::band_of(p.y, self.low, self.step, self.bands.len());
3382 let at = |q: Point2| [q.x, q.y];
3383 let mut inside = false;
3384 for &i in &self.bands[band] {
3385 let (a, b) = self.edges[i as usize];
3386 if (a.y > p.y) == (b.y > p.y) {
3387 continue;
3388 }
3389 let side = Exact::orient2d(at(a), at(b), [p.x, p.y]);
3390 let rightwards = if b.y > a.y {
3391 side == ogeom_core::Sign::Positive
3392 } else {
3393 side == ogeom_core::Sign::Negative
3394 };
3395 if rightwards {
3396 inside = !inside;
3397 }
3398 }
3399 inside
3400 }
3401}
3402
3403/// The unit normal of a triangle, or `None` if it is degenerate.
3404#[must_use]
3405pub fn triangle_normal(a: Point, b: Point, c: Point, tol: Tolerances) -> Option<Direction> {
3406 Direction::from_cross(b - a, c - a, tol).ok()
3407}
3408
3409/// Discretize an edge into a polyline in space, for display or coarse queries.
3410///
3411/// # Errors
3412///
3413/// As [`discretize`].
3414pub fn polyline_of_edge(
3415 model: &Model,
3416 edge: &Shape,
3417 deflection: Deflection,
3418 tol: Tolerances,
3419) -> OgeomResult<Vec<Point>> {
3420 if model.kind_of(edge)? != ShapeType::Edge {
3421 ogeom_bail!(Construction, "expected an edge");
3422 }
3423 let Some(node) = model.node(edge) else {
3424 ogeom_bail!(Dangling, "edge is not in this model");
3425 };
3426 let NodeData::Edge(data) = node.data() else {
3427 ogeom_bail!(Construction, "edge node holds no edge data");
3428 };
3429 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
3430 return Ok(Vec::new());
3431 };
3432 let Some(geometry) = model.geometry().curve(*curve) else {
3433 ogeom_bail!(Dangling, "curve is not in this model");
3434 };
3435 let placement = edge.transform(model.datums())?;
3436 let line = discretize(geometry, *range, deflection, tol)?;
3437 let mut points: Vec<Point> = line.points.iter().map(|p| placement.apply(*p)).collect();
3438 if edge.orientation() == Orientation::Reversed {
3439 points.reverse();
3440 }
3441 Ok(points)
3442}
3443
3444#[cfg(test)]
3445#[allow(clippy::unwrap_used)]
3446mod tests {
3447 use super::*;
3448
3449 #[test]
3450 fn a_wild_chart_refuses_instead_of_crashing() {
3451 let plane: SurfaceGeometry = ogeom_geom::PlaneSurface::over(
3452 ogeom_math::Plane::through(
3453 ogeom_math::Point::new(0.0, 0.0, 0.0),
3454 ogeom_math::Direction::Z,
3455 ),
3456 (-10.0, 10.0),
3457 (-10.0, 10.0),
3458 )
3459 .unwrap()
3460 .into();
3461 let tol = Tolerances::millimetres();
3462 // Coordinates wilder than any face: refused by the screen.
3463 let wild = vec![vec![
3464 Point2::new(0.0, 0.0),
3465 Point2::new(1e15, 0.0),
3466 Point2::new(0.0, 1e15),
3467 ]];
3468 let Err(err) = triangulate_region(&wild, &plane, Deflection::default(), tol) else {
3469 panic!("a wild chart must refuse");
3470 };
3471 assert!(err.to_string().contains("describes no face"), "{err}");
3472 // A non-finite coordinate: refused by name, not fed to the library.
3473 let nan = vec![vec![
3474 Point2::new(0.0, 0.0),
3475 Point2::new(f64::NAN, 1.0),
3476 Point2::new(1.0, 1.0),
3477 ]];
3478 let Err(err) = triangulate_region(&nan, &plane, Deflection::default(), tol) else {
3479 panic!("a NaN chart must refuse");
3480 };
3481 assert!(err.to_string().contains("non-finite"), "{err}");
3482 }
3483
3484 use approx::assert_relative_eq;
3485 use ogeom_algo::make_box;
3486 use ogeom_math::Frame;
3487 use ogeom_topo::explore_unique;
3488
3489 const T: Tolerances = Tolerances::millimetres();
3490
3491 /// `refine_direction` as it was written: rescan from zero after every
3492 /// split. Kept here as the reference the cursor form is held to.
3493 fn refine_by_rescan<F: Fn(f64, f64) -> f64>(lo: f64, hi: f64, chord: f64, sag: F) -> Vec<f64> {
3494 let mut values = vec![lo, f64::midpoint(lo, hi), hi];
3495 while values.len() < MAX_DIRECTION_STEPS {
3496 let Some(i) = (0..values.len() - 1).find(|&i| sag(values[i], values[i + 1]) > chord)
3497 else {
3498 break;
3499 };
3500 let mid = f64::midpoint(values[i], values[i + 1]);
3501 if mid <= values[i] || mid >= values[i + 1] {
3502 break;
3503 }
3504 values.insert(i + 1, mid);
3505 }
3506 values
3507 }
3508
3509 #[test]
3510 fn walking_forward_splits_where_rescanning_did() {
3511 // The cursor is only sound because splitting an interval cannot change
3512 // whether an earlier one sags. Held to the old form's output exactly,
3513 // over sag profiles that bite in different places: flat, steep at one
3514 // end, periodic, and one savage enough to reach the step cap.
3515 /// A named sag profile to hold both forms to.
3516 type Profile = (&'static str, Box<dyn Fn(f64, f64) -> f64>);
3517 let cases: Vec<Profile> = vec![
3518 ("flat", Box::new(|_a: f64, _b: f64| 0.0)),
3519 ("width", Box::new(|a: f64, b: f64| (b - a).abs())),
3520 (
3521 "steep at the low end",
3522 Box::new(|a: f64, b: f64| (b - a).abs() / a.abs().max(1e-3)),
3523 ),
3524 (
3525 "periodic",
3526 Box::new(|a: f64, b: f64| (b - a).abs() * (a * 12.0).sin().abs()),
3527 ),
3528 (
3529 "beyond the cap",
3530 Box::new(|a: f64, b: f64| (b - a).abs() * 1e6),
3531 ),
3532 ];
3533 for (name, sag) in cases {
3534 for chord in [1.0, 0.1, 0.01, 1e-3] {
3535 let walked = refine_direction(0.0, 1.0, chord, &sag);
3536 let rescanned = refine_by_rescan(0.0, 1.0, chord, &sag);
3537 assert_eq!(
3538 walked, rescanned,
3539 "{name} at chord {chord}: the cursor split somewhere the rescan did not"
3540 );
3541 }
3542 }
3543 }
3544
3545 #[test]
3546 fn banded_containment_answers_what_the_full_scan_answers() {
3547 // The index may only decline to visit edges that could not have
3548 // counted. Held to the unindexed predicate over a ring with a hole,
3549 // on a grid that straddles both boundaries and the vertices themselves.
3550 let outer: Vec<Point2> = vec![
3551 Point2::new(0.0, 0.0),
3552 Point2::new(4.0, 0.0),
3553 Point2::new(4.0, 3.0),
3554 Point2::new(2.0, 1.5),
3555 Point2::new(0.0, 3.0),
3556 ];
3557 let hole: Vec<Point2> = vec![
3558 Point2::new(1.0, 0.5),
3559 Point2::new(1.0, 1.0),
3560 Point2::new(1.5, 1.0),
3561 Point2::new(1.5, 0.5),
3562 ];
3563 let rings = vec![outer, hole];
3564 let bands = RingBands::over(&rings);
3565 for i in 0..=80 {
3566 for j in 0..=60 {
3567 #[allow(clippy::cast_precision_loss)]
3568 let p = Point2::new(f64::from(i) * 0.05 - 0.1, f64::from(j) * 0.05 - 0.1);
3569 assert_eq!(
3570 bands.holds(p),
3571 inside_region(&rings, p),
3572 "the index disagreed with the full scan at {p:?}"
3573 );
3574 }
3575 }
3576 }
3577
3578 fn fine() -> Deflection {
3579 Deflection {
3580 chord: 1e-3,
3581 angular: 0.05,
3582 ..Deflection::default()
3583 }
3584 }
3585
3586 /// A vertex at a cone's apex or a sphere's pole carries the normal the
3587 /// surface tends to there along its own column, not nothing.
3588 #[test]
3589 fn apex_and_pole_vertices_carry_the_limit_normal() {
3590 let mut model = Model::new();
3591 let cone = ogeom_algo::make_cone(&mut model, Frame::WORLD, 2.0, 0.0, 3.0, T).unwrap();
3592 let sphere = ogeom_algo::make_sphere(&mut model, Frame::WORLD, 1.5, T).unwrap();
3593 for (shape, what) in [(&cone.shape, "cone"), (&sphere.shape, "sphere")] {
3594 for face in explore_unique(&model, shape, ShapeType::Face).unwrap() {
3595 let mesh = triangulate_face(&model, &face, fine(), T).unwrap();
3596 for (i, n) in mesh.normals.iter().enumerate() {
3597 assert!(
3598 (n.magnitude() - 1.0).abs() < 1e-9,
3599 "{what} vertex {i} at {:?} has normal {n:?}",
3600 mesh.positions[i]
3601 );
3602 }
3603 }
3604 }
3605 // At the apex the limit normal along a ruling is that ruling's
3606 // normal: on a cone of half-angle atan(2/3) it leans out by that
3607 // much from the axis, the same as every other normal in its column.
3608 let faces = explore_unique(&model, &cone.shape, ShapeType::Face).unwrap();
3609 let lean = (2.0_f64 / 3.0).atan();
3610 for face in &faces {
3611 let mesh = triangulate_face(&model, face, fine(), T).unwrap();
3612 for (i, p) in mesh.positions.iter().enumerate() {
3613 if p.distance(Point::new(0.0, 0.0, 3.0)) < 1e-9 {
3614 let n = mesh.normals[i];
3615 let from_axis = n.z.abs().acos();
3616 assert!(
3617 ((std::f64::consts::FRAC_PI_2 - from_axis) - lean).abs() < 1e-6,
3618 "apex normal {n:?} leans {from_axis} from the axis"
3619 );
3620 }
3621 }
3622 }
3623 }
3624
3625 #[test]
3626 fn a_box_face_triangulates_into_two_triangles() {
3627 // A planar square needs no interior points at all, which is what makes
3628 // deflection-driven refinement worth having: a fixed grid would add
3629 // dozens that buy nothing.
3630 let mut model = Model::new();
3631 let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
3632 let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
3633
3634 for face in &faces {
3635 let mesh = triangulate_face(&model, face, fine(), T).unwrap();
3636 assert_eq!(mesh.triangle_count(), 2, "a rectangle is two triangles");
3637 assert_eq!(mesh.vertex_count(), 4);
3638 assert!(mesh.deflection_met);
3639 }
3640 }
3641
3642 #[test]
3643 fn a_boxs_mesh_is_closed_and_reports_the_right_volume() {
3644 // The end-to-end check: triangulate, weld, and ask the mesh what it
3645 // encloses. A volume that comes out negative would mean the faces are
3646 // wound inward; one that is wrong in magnitude would mean the
3647 // triangulation is not covering the boundary.
3648 let mut model = Model::new();
3649 let size = (2.0, 3.0, 4.0);
3650 let built = make_box(&mut model, Frame::WORLD, size, T).unwrap();
3651 let mesh = triangulate(&model, &built.shape, fine(), T).unwrap();
3652
3653 assert_eq!(mesh.triangle_count(), 12, "six faces, two triangles each");
3654 assert_eq!(mesh.vertex_count(), 8, "welding merged the shared corners");
3655 assert!(
3656 mesh.is_closed(),
3657 "every triangle edge should be shared by two"
3658 );
3659 assert_relative_eq!(mesh.volume(), size.0 * size.1 * size.2, epsilon = 1e-9);
3660 assert_relative_eq!(
3661 mesh.area(),
3662 2.0 * (size.0 * size.1 + size.1 * size.2 + size.2 * size.0),
3663 epsilon = 1e-9
3664 );
3665 }
3666
3667 #[test]
3668 fn welding_is_what_closes_the_mesh() {
3669 // Without it each face brings its own copy of every boundary vertex, so
3670 // no triangle edge is shared and the surface is a pile of loose squares.
3671 let mut model = Model::new();
3672 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
3673
3674 let mut loose = Triangulation::new();
3675 for face in ogeom_topo::explore(
3676 &model,
3677 &built.shape,
3678 ogeom_topo::Filter::OfType(ShapeType::Face),
3679 )
3680 .unwrap()
3681 {
3682 loose.append(&triangulate_face(&model, &face, fine(), T).unwrap());
3683 }
3684 assert_eq!(loose.vertex_count(), 24, "four corners per face, unmerged");
3685 assert!(!loose.is_closed());
3686
3687 let welded = loose.welded(T);
3688 assert_eq!(welded.vertex_count(), 8);
3689 assert!(welded.is_closed());
3690 }
3691
3692 #[test]
3693 fn a_reversed_face_presents_the_other_side() {
3694 // A renderer or a volume computation that ignored orientation would
3695 // have the solid inside out, and nothing about the positions says so.
3696 let mut model = Model::new();
3697 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
3698 let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
3699
3700 let forward = triangulate_face(&model, &face, fine(), T).unwrap();
3701 let backward = triangulate_face(&model, &face.reversed(), fine(), T).unwrap();
3702
3703 assert_eq!(forward.triangle_count(), backward.triangle_count());
3704 for (a, b) in forward.normals.iter().zip(&backward.normals) {
3705 assert!(a.is_equal(-*b, T), "normals did not flip: {a:?} vs {b:?}");
3706 }
3707 // And the winding flipped with them, so the two agree.
3708 let winding = |m: &Triangulation, i: usize| {
3709 let [a, b, c] = m.triangles[i].map(|k| m.positions[k as usize]);
3710 (b - a).cross(c - a)
3711 };
3712 assert!(winding(&forward, 0).dot(winding(&backward, 0)) < 0.0);
3713 }
3714
3715 #[test]
3716 fn every_triangle_vertex_lies_on_the_surface_it_came_from() {
3717 let mut model = Model::new();
3718 let built = make_box(&mut model, Frame::WORLD, (2.0, 1.0, 3.0), T).unwrap();
3719
3720 for face in explore_unique(&model, &built.shape, ShapeType::Face).unwrap() {
3721 let mesh = triangulate_face(&model, &face, fine(), T).unwrap();
3722 let data = model.node(&face).unwrap().data().as_face().unwrap().clone();
3723 let surface = model.geometry().surface(data.surface).unwrap();
3724 for (position, (u, v)) in mesh.positions.iter().zip(&mesh.parameters) {
3725 let exact = surface.point_at(*u, *v, T).unwrap();
3726 assert!(
3727 position.is_equal(exact, T),
3728 "{position:?} is not on its surface"
3729 );
3730 }
3731 }
3732 }
3733
3734 #[test]
3735 fn a_finer_deflection_never_gives_fewer_triangles() {
3736 let mut model = Model::new();
3737 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
3738 let coarse = triangulate(&model, &built.shape, Deflection::default(), T).unwrap();
3739 let detailed = triangulate(&model, &built.shape, fine(), T).unwrap();
3740 assert!(detailed.triangle_count() >= coarse.triangle_count());
3741 // Both enclose the same volume, since the faces are flat.
3742 assert_relative_eq!(coarse.volume(), 1.0, epsilon = 1e-9);
3743 assert_relative_eq!(detailed.volume(), 1.0, epsilon = 1e-9);
3744 }
3745
3746 #[test]
3747 fn a_sphere_gets_interior_points_and_converges_on_its_true_area() {
3748 // The whole point of measuring deflection in space rather than in
3749 // parameter space. A sphere's parameter rectangle is uniform; the
3750 // surface it maps to is not, and a fixed grid would be dense at the
3751 // poles and coarse at the equator.
3752 use ogeom_algo::make_natural_face;
3753 use ogeom_geom::SphereSurface;
3754 use ogeom_math::Sphere;
3755
3756 let radius = 10.0;
3757 let exact = 4.0 * std::f64::consts::PI * radius * radius;
3758 let mut previous = 0.0;
3759
3760 for chord in [1.0_f64, 0.25, 0.05] {
3761 let mut model = Model::new();
3762 let surface = SphereSurface::new(Sphere::new(Frame::WORLD, radius, T).unwrap());
3763 let face = make_natural_face(&mut model, surface.into()).unwrap().shape;
3764 let deflection = Deflection {
3765 chord,
3766 ..Deflection::default()
3767 };
3768 let mesh = triangulate_face(&model, &face, deflection, T).unwrap();
3769
3770 assert!(
3771 mesh.triangle_count() > 2,
3772 "a curved face needs interior points, got {} triangles",
3773 mesh.triangle_count()
3774 );
3775 // Every triangle chord-cuts the sphere, so the area comes in under
3776 // the truth and climbs as the tolerance tightens.
3777 let area = mesh.area();
3778 assert!(area < exact, "a chord-cut area cannot exceed the surface's");
3779 assert!(
3780 area > previous,
3781 "tightening the chord from the previous step lost area: \
3782 {area} after {previous}"
3783 );
3784 previous = area;
3785 }
3786 assert!(
3787 previous > exact * 0.99,
3788 "at a chord of 0.05 on a radius of 10 the area should be within a \
3789 percent, got {previous} against {exact}"
3790 );
3791 }
3792
3793 #[test]
3794 fn every_sphere_vertex_sits_at_the_right_radius() {
3795 // Lifting through the surface is what makes the mesh curved at all; a
3796 // vertex left in parameter space, or lifted with the wrong parameters,
3797 // would land nowhere near the sphere.
3798 use ogeom_algo::make_natural_face;
3799 use ogeom_geom::SphereSurface;
3800 use ogeom_math::Sphere;
3801
3802 let mut model = Model::new();
3803 let surface = SphereSurface::new(Sphere::new(Frame::WORLD, 3.0, T).unwrap());
3804 let face = make_natural_face(&mut model, surface.into()).unwrap().shape;
3805 let mesh = triangulate_face(&model, &face, Deflection::default(), T).unwrap();
3806
3807 for p in &mesh.positions {
3808 assert_relative_eq!(p.to_vector().magnitude(), 3.0, epsilon = 1e-9);
3809 }
3810 }
3811
3812 #[test]
3813 fn a_curved_domains_boundary_is_refined_not_just_its_corners() {
3814 // The corners alone would leave the triangulation nothing to connect to
3815 // along a side, so it reaches right across the domain for one, and a
3816 // sliver from a sphere's equator to its pole makes the summed area
3817 // diverge under refinement rather than converge.
3818 use ogeom_geom::{PlaneSurface, SphereSurface};
3819 use ogeom_math::{Plane, Sphere};
3820
3821 let sphere: SurfaceGeometry =
3822 SphereSurface::new(Sphere::new(Frame::WORLD, 10.0, T).unwrap()).into();
3823 let coarse = domain_ring(&sphere, Deflection::default(), T);
3824 let fine_ring = domain_ring(&sphere, fine(), T);
3825 assert!(coarse.len() > 4, "a sphere's domain edge is curved");
3826 assert!(
3827 fine_ring.len() > coarse.len(),
3828 "a tighter chord should place more boundary points"
3829 );
3830
3831 // No side may repeat a corner: a zero-length constraint is not one.
3832 for w in fine_ring.windows(2) {
3833 assert!(!w[0].is_equal(w[1], T), "the ring repeats a point");
3834 }
3835
3836 // A plane is flat, so its domain needs only what closes the rectangle.
3837 let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
3838 assert!(domain_ring(&plane, fine(), T).len() >= 4);
3839 }
3840
3841 #[test]
3842 fn winding_detects_points_inside_and_outside_a_ring() {
3843 let square = vec![
3844 Point2::new(0.0, 0.0),
3845 Point2::new(1.0, 0.0),
3846 Point2::new(1.0, 1.0),
3847 Point2::new(0.0, 1.0),
3848 ];
3849 assert!(inside_region(
3850 std::slice::from_ref(&square),
3851 Point2::new(0.5, 0.5)
3852 ));
3853 assert!(!inside_region(
3854 std::slice::from_ref(&square),
3855 Point2::new(1.5, 0.5)
3856 ));
3857 assert!(!inside_region(
3858 std::slice::from_ref(&square),
3859 Point2::new(0.5, -0.5)
3860 ));
3861
3862 // With a hole, the middle is outside again.
3863 let hole = vec![
3864 Point2::new(0.4, 0.4),
3865 Point2::new(0.6, 0.4),
3866 Point2::new(0.6, 0.6),
3867 Point2::new(0.4, 0.6),
3868 ];
3869 let with_hole = vec![square, hole];
3870 assert!(!inside_region(&with_hole, Point2::new(0.5, 0.5)));
3871 assert!(inside_region(&with_hole, Point2::new(0.2, 0.2)));
3872 }
3873
3874 #[test]
3875 fn a_polyline_of_an_edge_runs_in_the_edges_direction() {
3876 let mut model = Model::new();
3877 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
3878 let edge = explore_unique(&model, &built.shape, ShapeType::Edge).unwrap()[0].clone();
3879
3880 let forward = polyline_of_edge(&model, &edge, fine(), T).unwrap();
3881 let backward = polyline_of_edge(&model, &edge.reversed(), fine(), T).unwrap();
3882 assert!(forward.len() >= 2);
3883 assert!(forward[0].is_equal(backward[backward.len() - 1], T));
3884 assert!(forward[forward.len() - 1].is_equal(backward[0], T));
3885 }
3886
3887 #[test]
3888 fn triangulating_something_that_is_not_a_face_is_refused() {
3889 let mut model = Model::new();
3890 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
3891 assert!(triangulate_face(&model, &built.shape, fine(), T).is_err());
3892
3893 let vertex = explore_unique(&model, &built.shape, ShapeType::Vertex).unwrap()[0].clone();
3894 assert!(triangulate_face(&model, &vertex, fine(), T).is_err());
3895 assert!(polyline_of_edge(&model, &vertex, fine(), T).is_err());
3896 }
3897
3898 #[test]
3899 fn a_triangles_normal_is_none_when_it_is_degenerate() {
3900 let a = Point::ORIGIN;
3901 let b = Point::new(1.0, 0.0, 0.0);
3902 assert!(triangle_normal(a, b, Point::new(0.0, 1.0, 0.0), T).is_some());
3903 assert!(triangle_normal(a, b, Point::new(2.0, 0.0, 0.0), T).is_none());
3904 assert!(triangle_normal(a, a, a, T).is_none());
3905 }
3906}
3907
3908#[cfg(test)]
3909#[allow(clippy::unwrap_used)]
3910mod predicate_tests {
3911 use super::*;
3912 use ogeom_core::Fast;
3913
3914 /// A triangle with one very long, very nearly diagonal edge.
3915 ///
3916 /// The configuration where a naive crossing test goes wrong. Subtracting
3917 /// the edge's ends from a query point close to the line cancels almost
3918 /// every significant digit, and what is left is rounding rather than
3919 /// geometry.
3920 fn sliver() -> Vec<Vec<Point2>> {
3921 vec![vec![
3922 Point2::new(0.5, 0.5),
3923 Point2::new(1000.0, 1000.0),
3924 Point2::new(1000.0, 0.5),
3925 ]]
3926 }
3927
3928 #[test]
3929 fn the_two_implementations_are_a_real_choice_and_not_a_decoration() {
3930 // The point of the seam. In a band of near-degenerate queries the two
3931 // answer differently, and if they never did, routing through the trait
3932 // would be ceremony rather than a design.
3933 //
3934 // The query points straddle the long edge at a spacing far below what
3935 // the subtraction can resolve, which is exactly the case a mesh hits
3936 // when a face's boundary passes close to a triangulation vertex.
3937 let rings = sliver();
3938 let step = 2.0_f64.powi(-48);
3939 let mut disagreements = 0;
3940 for i in 0..256i32 {
3941 for j in 0..256i32 {
3942 let p = Point2::new(
3943 250.0 + f64::from(i - 128) * step,
3944 250.0 + f64::from(j - 128) * step,
3945 );
3946 if inside_boundary_with::<Exact>(&rings, p)
3947 != inside_boundary_with::<Fast>(&rings, p)
3948 {
3949 disagreements += 1;
3950 }
3951 }
3952 }
3953 assert!(
3954 disagreements > 0,
3955 "the exact and fast predicates never disagreed, so the seam is not \
3956 carrying anything"
3957 );
3958 }
3959
3960 #[test]
3961 fn the_exact_predicate_is_the_one_that_is_right_near_the_edge() {
3962 // Disagreeing is not enough; the exact one has to be the *correct*
3963 // one, and that needs points whose side is known without asking either
3964 // implementation.
3965 //
3966 // Stepped in units of the last place rather than by a small distance.
3967 // A distance below the spacing of `f64` at 250 rounds away entirely,
3968 // leaving two points that are both exactly *on* the diagonal, where
3969 // either answer is defensible and the test would be asserting nothing.
3970 let rings = sliver();
3971 let base = 250.0_f64;
3972 let mut off = base;
3973 for k in 1..64 {
3974 off = off.next_up();
3975 assert_ne!(off, base, "step {k} did not move the point at all");
3976 // Inside this triangle is below the diagonal `y = x`: larger x.
3977 assert!(
3978 inside_boundary_with::<Exact>(&rings, Point2::new(off, base)),
3979 "a point {k} ulps below the diagonal was reported outside"
3980 );
3981 assert!(
3982 !inside_boundary_with::<Exact>(&rings, Point2::new(base, off)),
3983 "a point {k} ulps above the diagonal was reported inside"
3984 );
3985 }
3986 }
3987
3988 #[test]
3989 fn the_exact_answer_is_the_one_the_geometry_supports() {
3990 // Points placed by construction, so the right answer is known without
3991 // asking either implementation.
3992 let square = vec![vec![
3993 Point2::new(0.0, 0.0),
3994 Point2::new(1.0, 0.0),
3995 Point2::new(1.0, 1.0),
3996 Point2::new(0.0, 1.0),
3997 ]];
3998 assert!(inside_boundary_with::<Exact>(
3999 &square,
4000 Point2::new(0.5, 0.5)
4001 ));
4002 assert!(!inside_boundary_with::<Exact>(
4003 &square,
4004 Point2::new(1.5, 0.5)
4005 ));
4006 assert!(!inside_boundary_with::<Exact>(
4007 &square,
4008 Point2::new(-0.5, 0.5)
4009 ));
4010 assert!(!inside_boundary_with::<Exact>(
4011 &square,
4012 Point2::new(0.5, 1.5)
4013 ));
4014
4015 // A vertex exactly on the sampling ray is counted once, not twice or
4016 // not at all, which is what the half-open comparison is for.
4017 let diamond = vec![vec![
4018 Point2::new(0.0, 0.0),
4019 Point2::new(1.0, 1.0),
4020 Point2::new(2.0, 0.0),
4021 Point2::new(1.0, -1.0),
4022 ]];
4023 assert!(inside_boundary_with::<Exact>(
4024 &diamond,
4025 Point2::new(1.0, 0.0)
4026 ));
4027 assert!(!inside_boundary_with::<Exact>(
4028 &diamond,
4029 Point2::new(3.0, 0.0)
4030 ));
4031 assert!(!inside_boundary_with::<Exact>(
4032 &diamond,
4033 Point2::new(-1.0, 0.0)
4034 ));
4035 }
4036
4037 /// A cylinder's grid has as many rows as its length needs, not as
4038 /// many as its sag asks for.
4039 #[test]
4040 fn rows_are_spread_until_no_cell_is_taller_than_its_aspect() {
4041 // Sixteen columns a millimetre wide over a region a hundred long:
4042 // sag left one interior row, the aspect wants cells six tall.
4043 let sagged = vec![0.0, 50.0, 100.0];
4044 let spread = spread_to_aspect(
4045 sagged.clone(),
4046 0.0,
4047 16.0,
4048 |_| 17,
4049 |a, b| (b - a).abs(),
4050 |a, b, _| (b - a).abs(),
4051 );
4052 assert_eq!(
4053 spread.len(),
4054 2 * 9 + 1,
4055 "each 50 mm half in nine 6 mm pieces: {spread:?}"
4056 );
4057 assert_eq!(spread[0], 0.0);
4058 assert_eq!(spread[9], 50.0, "the rows sag chose stay where they were");
4059 assert_eq!(spread[18], 100.0);
4060 assert!(spread.windows(2).all(|w| w[1] > w[0]));
4061
4062 // Three columns is flat along `u` as well; nothing to hold to.
4063 let flat = spread_to_aspect(
4064 sagged.clone(),
4065 0.0,
4066 16.0,
4067 |_| 3,
4068 |a, b| (b - a).abs(),
4069 |a, b, _| (b - a).abs(),
4070 );
4071 assert_eq!(flat, sagged);
4072
4073 // Cells already shorter than the aspect are left alone.
4074 let fine = vec![0.0, 4.0, 8.0];
4075 let same = spread_to_aspect(
4076 fine.clone(),
4077 0.0,
4078 16.0,
4079 |_| 17,
4080 |a, b| (b - a).abs(),
4081 |a, b, _| (b - a).abs(),
4082 );
4083 assert_eq!(same, fine);
4084 }
4085
4086 /// A ring's slop-duplicates and spikes come off before it is
4087 /// triangulated.
4088 #[test]
4089 fn a_ring_is_cleaned_of_duplicates_and_spikes() {
4090 let p = |x: f64, y: f64| Point2::new(x, y);
4091 // A square whose closing point repeats its first a hair off, and
4092 // whose right side steps out to a point and straight back.
4093 let mut ring = vec![
4094 p(0.0, 0.0),
4095 p(10.0, 0.0),
4096 p(10.0, 5.0),
4097 p(10.2, 5.0),
4098 p(10.0, 5.0 + 1e-9),
4099 p(10.0, 10.0),
4100 p(0.0, 10.0),
4101 p(1e-9, 1e-9),
4102 ];
4103 let mut anchors = vec![None; ring.len()];
4104 let reach = chart_reach(&ring);
4105 merge_near_duplicates(&mut ring, &mut anchors, reach);
4106 assert_eq!(ring.len(), 7, "the closing duplicate is gone: {ring:?}");
4107 remove_spikes(&mut ring, &mut anchors, reach);
4108 assert_eq!(
4109 ring,
4110 vec![
4111 p(0.0, 0.0),
4112 p(10.0, 0.0),
4113 p(10.0, 5.0),
4114 p(10.0, 10.0),
4115 p(0.0, 10.0)
4116 ],
4117 "the spike is gone and the ring is the square with a point on one side"
4118 );
4119 assert_eq!(anchors.len(), ring.len());
4120 }
4121
4122 #[test]
4123 fn a_hole_is_outside_however_either_ring_is_wound() {
4124 // Even-odd does not depend on the wires being wound consistently, which
4125 // is what makes it survive imported geometry.
4126 let outer = vec![
4127 Point2::new(0.0, 0.0),
4128 Point2::new(4.0, 0.0),
4129 Point2::new(4.0, 4.0),
4130 Point2::new(0.0, 4.0),
4131 ];
4132 let hole: Vec<Point2> = vec![
4133 Point2::new(1.0, 1.0),
4134 Point2::new(3.0, 1.0),
4135 Point2::new(3.0, 3.0),
4136 Point2::new(1.0, 3.0),
4137 ];
4138 let backwards: Vec<Point2> = hole.iter().rev().copied().collect();
4139 for inner in [hole, backwards] {
4140 let rings = vec![outer.clone(), inner];
4141 assert!(!inside_boundary_with::<Exact>(
4142 &rings,
4143 Point2::new(2.0, 2.0)
4144 ));
4145 assert!(inside_boundary_with::<Exact>(&rings, Point2::new(0.5, 0.5)));
4146 }
4147 }
4148}