1use ogeom_algo::{Built, History, make_edge_between, make_vertex, make_wire};
29use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
30use ogeom_geom::{Curve, Curve3d as _, LineCurve};
31use ogeom_math::{Point, Vector};
32use ogeom_mesh::{Deflection, triangulate, triangulate_face};
33use ogeom_topo::{Filter, Model, Shape, ShapeType, Triangulation, explore};
34use std::collections::HashMap;
35
36#[derive(Debug, Clone)]
38pub struct MiddlePath {
39 pub built: Built,
42 pub deviation: f64,
45}
46
47const MAX_REFINEMENTS: usize = 5;
49
50const MAX_STATIONS: usize = 4000;
52
53const SHARPEST_TURN: f64 = core::f64::consts::FRAC_1_SQRT_2;
58
59pub fn middle_path(
83 model: &mut Model,
84 solid: &Shape,
85 start: &Shape,
86 end: &Shape,
87 tolerance: f64,
88 tol: Tolerances,
89) -> OgeomResult<MiddlePath> {
90 if !tolerance.is_finite() || tolerance <= tol.confusion() {
91 ogeom_bail!(
92 Construction,
93 "a middle path to {tolerance} is not a distance"
94 );
95 }
96 if model.kind_of(solid)? != ShapeType::Solid {
97 ogeom_bail!(Construction, "a middle path runs through a solid");
98 }
99 let faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
100 for face in [start, end] {
101 if !faces.iter().any(|f| f.is_same(face)) {
102 ogeom_bail!(Construction, "the end faces must be faces of the solid");
103 }
104 }
105 if start.is_same(end) {
106 ogeom_bail!(Construction, "a middle path needs two different end faces");
107 }
108
109 let deflection = Deflection {
110 chord: tolerance * 0.25,
111 ..Deflection::default()
112 };
113 let mesh = Slicer::new(triangulate(model, solid, deflection, tol)?);
114 if !mesh.closed {
115 ogeom_bail!(
116 Construction,
117 "the solid's mesh does not close, so its sections are not regions"
118 );
119 }
120 let (c0, n0, area0) = face_centroid(model, start, deflection, tol)?;
121 let (ce, ne, _) = face_centroid(model, end, deflection, tol)?;
122 let size = (area0 / core::f64::consts::PI).sqrt();
123 if size <= tol.confusion() {
124 ogeom_bail!(Construction, "the start face has no area to start from");
125 }
126
127 let probe = size * 0.05;
130 let inward = |c: Point, n: Vector| {
131 if mesh.section(c + n * probe, n, tol).is_some() {
132 Some(n)
133 } else if mesh.section(c - n * probe, n, tol).is_some() {
134 Some(-n)
135 } else {
136 None
137 }
138 };
139 let Some(t0) = inward(c0, n0) else {
140 ogeom_bail!(NotDone, "no material lies behind the start face");
141 };
142
143 let mut step = size;
144 let mut last = Err("the middle path was not marched");
145 for _ in 0..=MAX_REFINEMENTS {
146 let forward = march(&mesh, c0, t0, ce, step, tol)?;
147 let path = if forward.stopped.is_none() {
148 let curve = fit_stations(&forward.stations, tolerance, tol)?;
149 let deviation = measure(&mesh, &curve, forward.stations.len(), tol)?;
150 Some((vec![curve], deviation))
151 } else if let Some(te) = inward(ce, ne) {
152 let backward = march(&mesh, ce, te, c0, step, tol)?;
153 if backward.stopped.is_none() {
154 let mut stations = backward.stations;
155 stations.reverse();
156 let curve = fit_stations(&stations, tolerance, tol)?;
157 let deviation = measure(&mesh, &curve, stations.len(), tol)?;
158 Some((vec![curve], deviation))
159 } else {
160 cornered(&mesh, &forward, &backward, tolerance, tol)?
161 }
162 } else {
163 None
164 };
165 match path {
166 Some((curves, deviation)) if deviation <= tolerance => {
167 return build(model, curves, start, end, deviation, tol);
168 }
169 Some((_, deviation)) => last = Ok(deviation),
170 None => {
171 if let Some(stopped) = forward.stopped {
172 last = Err(stopped);
173 }
174 }
175 }
176 step *= 0.5;
177 }
178 match last {
179 Ok(deviation) => ogeom_bail!(
180 NotDone,
181 "the middle path reached a deviation of {deviation} against a tolerance of {tolerance}"
182 ),
183 Err(stopped) => ogeom_bail!(NotDone, "{stopped}"),
184 }
185}
186
187struct Marched {
191 stations: Vec<Point>,
192 tangents: Vec<Vector>,
193 reaches: Vec<f64>,
194 stopped: Option<&'static str>,
195}
196
197fn march(
204 mesh: &Slicer,
205 c0: Point,
206 t0: Vector,
207 ce: Point,
208 step: f64,
209 tol: Tolerances,
210) -> OgeomResult<Marched> {
211 let first = mesh
212 .section(c0 + t0 * (step * 1e-3), t0, tol)
213 .map_or(0.0, |s| s.reach);
214 let mut marched = Marched {
215 stations: vec![c0],
216 tangents: vec![t0],
217 reaches: vec![first],
218 stopped: None,
219 };
220 let (mut c, mut t) = (c0, t0);
221 while c.distance(ce) > step * 1.25 || (ce - c).dot(t) < 0.5 * c.distance(ce) {
222 if marched.stations.len() > MAX_STATIONS {
223 ogeom_bail!(NotDone, "the middle path never reached the end face");
224 }
225 let Some(mut q) = mesh.section(c + t * step, t, tol) else {
226 marched.stopped = Some("a section square to the middle path finds no material");
227 return Ok(marched);
228 };
229 let mut turned = t;
230 for _ in 0..8 {
231 let chord = (q.centre - c).normalized(tol)?;
232 turned = (chord * (2.0 * t.dot(chord)) - t).normalized(tol)?;
233 let Some(next) = mesh.section(q.centre, turned, tol) else {
234 break;
235 };
236 let moved = next.centre.distance(q.centre);
237 q = next;
238 if moved <= tol.confusion() {
239 break;
240 }
241 }
242 if (q.centre - c).dot(t) <= 0.0 || turned.dot(t) < SHARPEST_TURN {
243 marched.stopped = Some("the middle path turned back on itself");
244 return Ok(marched);
245 }
246 marched.stations.push(q.centre);
247 marched.tangents.push(turned);
248 marched.reaches.push(q.reach);
249 (c, t) = (q.centre, turned);
250 }
251 marched.stations.push(ce);
252 marched.tangents.push(t);
253 marched.reaches.push(0.0);
254 Ok(marched)
255}
256
257fn cornered(
265 mesh: &Slicer,
266 forward: &Marched,
267 backward: &Marched,
268 tolerance: f64,
269 tol: Tolerances,
270) -> OgeomResult<Option<(Vec<Curve>, f64)>> {
271 let (mut a, mut b) = (forward.stations.len(), backward.stations.len());
272 let mut corner = None;
273 for _ in 0..8 {
274 if a == 0 || b == 0 {
275 return Ok(None);
276 }
277 let (pa, da) = leg_end(forward, a, tolerance, tol)?;
278 let (pb, db) = leg_end(backward, b, tolerance, tol)?;
279 let Some(k) = meeting(pa, da, pb, db, tolerance) else {
280 return Ok(None);
281 };
282 let cos_turn = da.dot(-db).clamp(-1.0, 1.0);
286 if cos_turn <= -0.99 {
287 return Ok(None);
288 }
289 let half_turn = ((1.0 - cos_turn) / (1.0 + cos_turn)).sqrt();
290 let clear = |marched: &Marched, upto: usize| {
291 (0..upto)
292 .take_while(|&i| {
293 marched.stations[i].distance(k)
294 > marched.reaches[i] * half_turn * 1.05 + tolerance
295 })
296 .count()
297 };
298 let (na, nb) = (clear(forward, a).max(1), clear(backward, b).max(1));
299 corner = Some((k, da, db));
300 if (na, nb) == (a, b) {
301 break;
302 }
303 (a, b) = (na, nb);
304 }
305 let Some((k, da, db)) = corner else {
306 return Ok(None);
307 };
308
309 let mut first: Vec<Point> = forward.stations[..a].to_vec();
310 first.push(k);
311 let mut second: Vec<Point> = backward.stations[..b].to_vec();
312 second.push(k);
313 second.reverse();
314 let legs = [
315 fit_stations(&first, tolerance, tol)?,
316 fit_stations(&second, tolerance, tol)?,
317 ];
318 let reach = forward.reaches[..a]
321 .iter()
322 .chain(&backward.reaches[..b])
323 .fold(0.0_f64, |m, r| m.max(*r));
324 let cos_turn = da.dot(-db).clamp(-1.0, 1.0);
325 let clear = reach * ((1.0 - cos_turn) / (1.0 + cos_turn)).sqrt() * 1.05 + tolerance;
326 let mut worst = 0.0_f64;
327 for (leg, stations) in legs.iter().zip([a, b]) {
328 worst = worst.max(measure_where(
329 mesh,
330 leg,
331 stations,
332 |p| p.distance(k) > clear,
333 tol,
334 )?);
335 }
336 let Some(halving) = mesh.section(k, da - db, tol) else {
337 return Ok(None);
338 };
339 worst = worst.max(halving.centre.distance(k));
340 Ok(Some((legs.to_vec(), worst)))
341}
342
343fn leg_end(
347 marched: &Marched,
348 upto: usize,
349 tolerance: f64,
350 tol: Tolerances,
351) -> OgeomResult<(Point, Vector)> {
352 if upto < 2 {
353 return Ok((marched.stations[0], marched.tangents[0]));
354 }
355 let curve = fit_stations(&marched.stations[..upto], tolerance, tol)?;
356 let (_, hi) = curve.domain();
357 Ok((
358 curve.point_at(hi, tol)?,
359 curve.d1_at(hi, tol)?.normalized(tol)?,
360 ))
361}
362
363fn meeting(pa: Point, da: Vector, pb: Point, db: Vector, tolerance: f64) -> Option<Point> {
367 let w = pa - pb;
368 let (aa, ab, bb) = (da.dot(da), da.dot(db), db.dot(db));
369 let (aw, bw) = (da.dot(w), db.dot(w));
370 let det = aa * bb - ab * ab;
371 if det <= 1e-12 * aa * bb {
372 return None;
373 }
374 let s = (ab * bw - bb * aw) / det;
375 let u = (aa * bw - ab * aw) / det;
376 if s < 0.0 || u < 0.0 {
377 return None;
378 }
379 let (qa, qb) = (pa + da * s, pb + db * u);
380 (qa.distance(qb) <= tolerance).then(|| qa.lerp(qb, 0.5))
381}
382
383fn fit_stations(stations: &[Point], tolerance: f64, tol: Tolerances) -> OgeomResult<Curve> {
385 let (first, last) = (stations[0], stations[stations.len() - 1]);
386 let axis = (last - first).normalized(tol)?;
387 let off_line = stations
388 .iter()
389 .map(|p| {
390 let d = *p - first;
391 (d - axis * d.dot(axis)).magnitude()
392 })
393 .fold(0.0_f64, f64::max);
394 if off_line <= tolerance * 0.05 {
395 return Ok(Curve::Line(LineCurve::segment(first, last, tol)?));
396 }
397 let fitted = ogeom_geom::fit::fit_points(stations, 3, tolerance * 0.1, tol)?;
398 Ok(Curve::BSpline(fitted.curve))
399}
400
401fn measure(mesh: &Slicer, curve: &Curve, stations: usize, tol: Tolerances) -> OgeomResult<f64> {
404 measure_where(mesh, curve, stations, |_| true, tol)
405}
406
407fn measure_where(
409 mesh: &Slicer,
410 curve: &Curve,
411 stations: usize,
412 keep: impl Fn(Point) -> bool,
413 tol: Tolerances,
414) -> OgeomResult<f64> {
415 let (lo, hi) = curve.domain();
416 let samples = (stations * 2).max(8);
417 let mut worst = 0.0_f64;
418 for i in 1..samples {
421 #[allow(clippy::cast_precision_loss)]
422 let u = lo + (hi - lo) * (i as f64) / (samples as f64);
423 let p = curve.point_at(u, tol)?;
424 if !keep(p) {
425 continue;
426 }
427 let d = curve.d1_at(u, tol)?.normalized(tol)?;
428 let Some(q) = mesh.section(p, d, tol) else {
429 ogeom_bail!(
430 NotDone,
431 "a section square to the fitted path finds no material"
432 );
433 };
434 worst = worst.max(q.centre.distance(p));
435 }
436 Ok(worst)
437}
438
439fn build(
442 model: &mut Model,
443 curves: Vec<Curve>,
444 start: &Shape,
445 end: &Shape,
446 deviation: f64,
447 tol: Tolerances,
448) -> OgeomResult<MiddlePath> {
449 let mut edges = Vec::with_capacity(curves.len());
450 let mut from: Option<Shape> = None;
451 for curve in curves {
452 let domain = curve.domain();
453 let from_vertex = match from.take() {
454 Some(v) => v,
455 None => make_vertex(model, curve.point_at(domain.0, tol)?).shape,
456 };
457 let to_vertex = make_vertex(model, curve.point_at(domain.1, tol)?).shape;
458 let edge = make_edge_between(model, curve, domain, &from_vertex, &to_vertex, tol)?.shape;
459 edges.push(edge);
460 from = Some(to_vertex);
461 }
462 let wire = make_wire(model, &edges, tol)?.shape;
463 let mut history = History::new();
464 history.generate(start, wire.clone());
465 history.generate(end, wire.clone());
466 Ok(MiddlePath {
467 built: Built::new(wire, history),
468 deviation,
469 })
470}
471
472fn face_centroid(
475 model: &Model,
476 face: &Shape,
477 deflection: Deflection,
478 tol: Tolerances,
479) -> OgeomResult<(Point, Vector, f64)> {
480 let mesh = triangulate_face(model, face, deflection, tol)?;
481 let (mut weighted, mut normal, mut area) = (Vector::ZERO, Vector::ZERO, 0.0);
482 for t in &mesh.triangles {
483 let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
484 let n = (b - a).cross(c - a) * 0.5;
485 let da = n.magnitude();
486 weighted += (a.to_vector() + b.to_vector() + c.to_vector()) * (da / 3.0);
487 normal += n;
488 area += da;
489 }
490 if area <= 0.0 {
491 ogeom_bail!(Construction, "an end face has no area");
492 }
493 Ok((
494 Point::from_vector(weighted * (1.0 / area)),
495 normal.normalized(tol)?,
496 area,
497 ))
498}
499
500struct Slicer {
502 mesh: Triangulation,
503 closed: bool,
504}
505
506impl Slicer {
507 fn new(mesh: Triangulation) -> Self {
508 let closed = !mesh.triangles.is_empty() && mesh.is_closed();
509 Self { mesh, closed }
510 }
511
512 fn section(&self, origin: Point, normal: Vector, tol: Tolerances) -> Option<Section> {
518 let n = normal.normalized(tol).ok()?;
519 let helper = if n.x.abs() < 0.9 {
520 Vector::X
521 } else {
522 Vector::Y
523 };
524 let e1 = n.cross(helper).normalized(tol).ok()?;
525 let e2 = n.cross(e1);
526 let flat = |p: Point| {
527 let d = p - origin;
528 (d.dot(e1), d.dot(e2))
529 };
530
531 let positions = &self.mesh.positions;
532 let side: Vec<f64> = positions.iter().map(|p| (*p - origin).dot(n)).collect();
533 let above = |i: u32| side[i as usize] >= 0.0;
536 let crossing = |a: u32, b: u32| {
537 let (da, db) = (side[a as usize], side[b as usize]);
538 let s = da / (da - db);
539 positions[a as usize].lerp(positions[b as usize], s)
540 };
541 let key = |a: u32, b: u32| if a < b { (a, b) } else { (b, a) };
542
543 let mut next: HashMap<(u32, u32), (u32, u32)> = HashMap::new();
546 let mut points: HashMap<(u32, u32), Point> = HashMap::new();
547 for t in &self.mesh.triangles {
548 let ups = t.iter().filter(|&&i| above(i)).count();
549 if ups == 0 || ups == 3 {
550 continue;
551 }
552 let mut sides = [(0u32, 0u32); 2];
553 let mut found = 0;
554 for k in 0..3 {
555 let (a, b) = (t[k], t[(k + 1) % 3]);
556 if above(a) != above(b) && found < 2 {
557 sides[found] = (a, b);
560 found += 1;
561 }
562 }
563 let (s0, s1) = if above(sides[0].0) {
564 (sides[0], sides[1])
565 } else {
566 (sides[1], sides[0])
567 };
568 points.insert(key(s0.0, s0.1), crossing(s0.0, s0.1));
569 points.insert(key(s1.0, s1.1), crossing(s1.0, s1.1));
570 next.insert(key(s0.0, s0.1), key(s1.0, s1.1));
571 }
572 if next.is_empty() {
573 return None;
574 }
575
576 let mut loops: Vec<Vec<(f64, f64)>> = Vec::new();
578 let mut seen: HashMap<(u32, u32), ()> = HashMap::new();
579 let mut starts: Vec<(u32, u32)> = next.keys().copied().collect();
580 starts.sort_unstable();
581 for s in starts {
582 if seen.contains_key(&s) {
583 continue;
584 }
585 let mut ring = Vec::new();
586 let mut at = s;
587 loop {
588 seen.insert(at, ());
589 ring.push(flat(points[&at]));
590 match next.get(&at) {
591 Some(&n) if n == s => break,
592 Some(&n) if !seen.contains_key(&n) => at = n,
593 _ => return None,
594 }
595 }
596 if ring.len() >= 3 {
597 loops.push(ring);
598 }
599 }
600
601 let measured: Vec<((f64, f64), f64)> = loops.iter().map(|l| area_centroid(l)).collect();
602 let outer = (0..loops.len())
606 .filter(|&i| contains(&loops[i], (0.0, 0.0)))
607 .max_by(|&a, &b| measured[a].1.abs().total_cmp(&measured[b].1.abs()))?;
608 let (mut sx, mut sy, mut sa) = {
609 let ((x, y), a) = measured[outer];
610 (x * a.abs(), y * a.abs(), a.abs())
611 };
612 for (i, ring) in loops.iter().enumerate() {
613 if i != outer && contains(&loops[outer], ring[0]) {
614 let ((x, y), a) = measured[i];
615 sx -= x * a.abs();
616 sy -= y * a.abs();
617 sa -= a.abs();
618 }
619 }
620 if sa <= 0.0 {
621 return None;
622 }
623 let (cx, cy) = (sx / sa, sy / sa);
624 let reach = loops[outer]
625 .iter()
626 .fold(0.0_f64, |m, &(x, y)| m.max((x - cx).hypot(y - cy)));
627 Some(Section {
628 centre: origin + e1 * cx + e2 * cy,
629 reach,
630 })
631 }
632}
633
634struct Section {
636 centre: Point,
637 reach: f64,
638}
639
640fn area_centroid(ring: &[(f64, f64)]) -> ((f64, f64), f64) {
642 let (mut a, mut cx, mut cy) = (0.0, 0.0, 0.0);
643 for i in 0..ring.len() {
644 let (p, q) = (ring[i], ring[(i + 1) % ring.len()]);
645 let w = p.0 * q.1 - q.0 * p.1;
646 a += w;
647 cx += (p.0 + q.0) * w;
648 cy += (p.1 + q.1) * w;
649 }
650 a *= 0.5;
651 if a == 0.0 {
652 return (ring[0], 0.0);
653 }
654 ((cx / (6.0 * a), cy / (6.0 * a)), a)
655}
656
657fn contains(ring: &[(f64, f64)], p: (f64, f64)) -> bool {
659 let mut inside = false;
660 for i in 0..ring.len() {
661 let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
662 if (a.1 > p.1) != (b.1 > p.1) {
663 let x = a.0 + (p.1 - a.1) / (b.1 - a.1) * (b.0 - a.0);
664 if x > p.0 {
665 inside = !inside;
666 }
667 }
668 }
669 inside
670}