1use ogeom_algo::{Built, History, make_edge, make_wire};
20use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
21use ogeom_geom::{Curve, Curve3d as _, LineCurve};
22use ogeom_math::{Point, Vector};
23use ogeom_mesh::{Deflection, triangulate, triangulate_face};
24use ogeom_topo::{Filter, Model, Shape, ShapeType, Triangulation, explore};
25use std::collections::HashMap;
26
27#[derive(Debug, Clone)]
29pub struct MiddlePath {
30 pub built: Built,
33 pub deviation: f64,
36}
37
38const MAX_REFINEMENTS: usize = 5;
40
41const MAX_STATIONS: usize = 4000;
43
44pub fn middle_path(
64 model: &mut Model,
65 solid: &Shape,
66 start: &Shape,
67 end: &Shape,
68 tolerance: f64,
69 tol: Tolerances,
70) -> OgeomResult<MiddlePath> {
71 if !tolerance.is_finite() || tolerance <= tol.confusion() {
72 ogeom_bail!(
73 Construction,
74 "a middle path to {tolerance} is not a distance"
75 );
76 }
77 if model.kind_of(solid)? != ShapeType::Solid {
78 ogeom_bail!(Construction, "a middle path runs through a solid");
79 }
80 let faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
81 for face in [start, end] {
82 if !faces.iter().any(|f| f.is_same(face)) {
83 ogeom_bail!(Construction, "the end faces must be faces of the solid");
84 }
85 }
86 if start.is_same(end) {
87 ogeom_bail!(Construction, "a middle path needs two different end faces");
88 }
89
90 let deflection = Deflection {
91 chord: tolerance * 0.25,
92 ..Deflection::default()
93 };
94 let mesh = Slicer::new(triangulate(model, solid, deflection, tol)?);
95 if !mesh.closed {
96 ogeom_bail!(
97 Construction,
98 "the solid's mesh does not close, so its sections are not regions"
99 );
100 }
101 let (c0, n0, area0) = face_centroid(model, start, deflection, tol)?;
102 let (ce, _, _) = face_centroid(model, end, deflection, tol)?;
103 let size = (area0 / core::f64::consts::PI).sqrt();
104 if size <= tol.confusion() {
105 ogeom_bail!(Construction, "the start face has no area to start from");
106 }
107
108 let probe = size * 0.05;
111 let t0 = if mesh.section(c0 + n0 * probe, n0, tol).is_some() {
112 n0
113 } else if mesh.section(c0 - n0 * probe, n0, tol).is_some() {
114 -n0
115 } else {
116 ogeom_bail!(NotDone, "no material lies behind the start face");
117 };
118
119 let mut step = size;
120 let mut last = None;
121 for _ in 0..=MAX_REFINEMENTS {
122 let stations = march(&mesh, c0, t0, ce, step, tol)?;
123 let curve = fit_stations(&stations, tolerance, tol)?;
124 let deviation = measure(&mesh, &curve, stations.len(), tol)?;
125 if deviation <= tolerance {
126 return build(model, curve, start, end, deviation, tol);
127 }
128 last = Some(deviation);
129 step *= 0.5;
130 }
131 ogeom_bail!(
132 NotDone,
133 "the middle path reached a deviation of {} against a tolerance of {tolerance}",
134 last.unwrap_or(f64::INFINITY)
135 )
136}
137
138fn march(
142 mesh: &Slicer,
143 c0: Point,
144 t0: Vector,
145 ce: Point,
146 step: f64,
147 tol: Tolerances,
148) -> OgeomResult<Vec<Point>> {
149 let mut stations = vec![c0];
150 let (mut c, mut t) = (c0, t0);
151 while c.distance(ce) > step * 1.25 || (ce - c).dot(t) < 0.5 * c.distance(ce) {
152 if stations.len() > MAX_STATIONS {
153 ogeom_bail!(NotDone, "the middle path never reached the end face");
154 }
155 let Some(mut q) = mesh.section(c + t * step, t, tol) else {
156 ogeom_bail!(
157 NotDone,
158 "a section square to the path finds no material {} along from {:?}",
159 step,
160 c
161 );
162 };
163 let mut turned = t;
164 for _ in 0..8 {
165 let chord = (q - c).normalized(tol)?;
166 turned = (chord * (2.0 * t.dot(chord)) - t).normalized(tol)?;
167 let Some(next) = mesh.section(q, turned, tol) else {
168 break;
169 };
170 let moved = next.distance(q);
171 q = next;
172 if moved <= tol.confusion() {
173 break;
174 }
175 }
176 if (q - c).dot(t) <= 0.0 {
179 ogeom_bail!(NotDone, "the middle path turned back on itself");
180 }
181 stations.push(q);
182 (c, t) = (q, turned);
183 }
184 stations.push(ce);
185 Ok(stations)
186}
187
188fn fit_stations(stations: &[Point], tolerance: f64, tol: Tolerances) -> OgeomResult<Curve> {
190 let (first, last) = (stations[0], stations[stations.len() - 1]);
191 let axis = (last - first).normalized(tol)?;
192 let off_line = stations
193 .iter()
194 .map(|p| {
195 let d = *p - first;
196 (d - axis * d.dot(axis)).magnitude()
197 })
198 .fold(0.0_f64, f64::max);
199 if off_line <= tolerance * 0.05 {
200 return Ok(Curve::Line(LineCurve::segment(first, last, tol)?));
201 }
202 let fitted = ogeom_geom::fit::fit_points(stations, 3, tolerance * 0.1, tol)?;
203 Ok(Curve::BSpline(fitted.curve))
204}
205
206fn measure(mesh: &Slicer, curve: &Curve, stations: usize, tol: Tolerances) -> OgeomResult<f64> {
209 let (lo, hi) = curve.domain();
210 let samples = (stations * 2).max(8);
211 let mut worst = 0.0_f64;
212 for i in 1..samples {
215 #[allow(clippy::cast_precision_loss)]
216 let u = lo + (hi - lo) * (i as f64) / (samples as f64);
217 let p = curve.point_at(u, tol)?;
218 let d = curve.d1_at(u, tol)?.normalized(tol)?;
219 let Some(q) = mesh.section(p, d, tol) else {
220 ogeom_bail!(
221 NotDone,
222 "a section square to the fitted path finds no material"
223 );
224 };
225 worst = worst.max(q.distance(p));
226 }
227 Ok(worst)
228}
229
230fn build(
231 model: &mut Model,
232 curve: Curve,
233 start: &Shape,
234 end: &Shape,
235 deviation: f64,
236 tol: Tolerances,
237) -> OgeomResult<MiddlePath> {
238 let domain = curve.domain();
239 let edge = make_edge(model, curve, domain, tol)?.shape;
240 let wire = make_wire(model, &[edge], tol)?.shape;
241 let mut history = History::new();
242 history.generate(start, wire.clone());
243 history.generate(end, wire.clone());
244 Ok(MiddlePath {
245 built: Built::new(wire, history),
246 deviation,
247 })
248}
249
250fn face_centroid(
253 model: &Model,
254 face: &Shape,
255 deflection: Deflection,
256 tol: Tolerances,
257) -> OgeomResult<(Point, Vector, f64)> {
258 let mesh = triangulate_face(model, face, deflection, tol)?;
259 let (mut weighted, mut normal, mut area) = (Vector::ZERO, Vector::ZERO, 0.0);
260 for t in &mesh.triangles {
261 let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
262 let n = (b - a).cross(c - a) * 0.5;
263 let da = n.magnitude();
264 weighted += (a.to_vector() + b.to_vector() + c.to_vector()) * (da / 3.0);
265 normal += n;
266 area += da;
267 }
268 if area <= 0.0 {
269 ogeom_bail!(Construction, "an end face has no area");
270 }
271 Ok((
272 Point::from_vector(weighted * (1.0 / area)),
273 normal.normalized(tol)?,
274 area,
275 ))
276}
277
278struct Slicer {
280 mesh: Triangulation,
281 closed: bool,
282}
283
284impl Slicer {
285 fn new(mesh: Triangulation) -> Self {
286 let closed = !mesh.triangles.is_empty() && mesh.is_closed();
287 Self { mesh, closed }
288 }
289
290 fn section(&self, origin: Point, normal: Vector, tol: Tolerances) -> Option<Point> {
295 let n = normal.normalized(tol).ok()?;
296 let helper = if n.x.abs() < 0.9 {
297 Vector::X
298 } else {
299 Vector::Y
300 };
301 let e1 = n.cross(helper).normalized(tol).ok()?;
302 let e2 = n.cross(e1);
303 let flat = |p: Point| {
304 let d = p - origin;
305 (d.dot(e1), d.dot(e2))
306 };
307
308 let positions = &self.mesh.positions;
309 let side: Vec<f64> = positions.iter().map(|p| (*p - origin).dot(n)).collect();
310 let above = |i: u32| side[i as usize] >= 0.0;
313 let crossing = |a: u32, b: u32| {
314 let (da, db) = (side[a as usize], side[b as usize]);
315 let s = da / (da - db);
316 positions[a as usize].lerp(positions[b as usize], s)
317 };
318 let key = |a: u32, b: u32| if a < b { (a, b) } else { (b, a) };
319
320 let mut next: HashMap<(u32, u32), (u32, u32)> = HashMap::new();
323 let mut points: HashMap<(u32, u32), Point> = HashMap::new();
324 for t in &self.mesh.triangles {
325 let ups = t.iter().filter(|&&i| above(i)).count();
326 if ups == 0 || ups == 3 {
327 continue;
328 }
329 let mut sides = [(0u32, 0u32); 2];
330 let mut found = 0;
331 for k in 0..3 {
332 let (a, b) = (t[k], t[(k + 1) % 3]);
333 if above(a) != above(b) && found < 2 {
334 sides[found] = (a, b);
337 found += 1;
338 }
339 }
340 let (s0, s1) = if above(sides[0].0) {
341 (sides[0], sides[1])
342 } else {
343 (sides[1], sides[0])
344 };
345 points.insert(key(s0.0, s0.1), crossing(s0.0, s0.1));
346 points.insert(key(s1.0, s1.1), crossing(s1.0, s1.1));
347 next.insert(key(s0.0, s0.1), key(s1.0, s1.1));
348 }
349 if next.is_empty() {
350 return None;
351 }
352
353 let mut loops: Vec<Vec<(f64, f64)>> = Vec::new();
355 let mut seen: HashMap<(u32, u32), ()> = HashMap::new();
356 let mut starts: Vec<(u32, u32)> = next.keys().copied().collect();
357 starts.sort_unstable();
358 for s in starts {
359 if seen.contains_key(&s) {
360 continue;
361 }
362 let mut ring = Vec::new();
363 let mut at = s;
364 loop {
365 seen.insert(at, ());
366 ring.push(flat(points[&at]));
367 match next.get(&at) {
368 Some(&n) if n == s => break,
369 Some(&n) if !seen.contains_key(&n) => at = n,
370 _ => return None,
371 }
372 }
373 if ring.len() >= 3 {
374 loops.push(ring);
375 }
376 }
377
378 let measured: Vec<((f64, f64), f64)> = loops.iter().map(|l| area_centroid(l)).collect();
379 let outer = (0..loops.len())
383 .filter(|&i| contains(&loops[i], (0.0, 0.0)))
384 .max_by(|&a, &b| measured[a].1.abs().total_cmp(&measured[b].1.abs()))?;
385 let (mut sx, mut sy, mut sa) = {
386 let ((x, y), a) = measured[outer];
387 (x * a.abs(), y * a.abs(), a.abs())
388 };
389 for (i, ring) in loops.iter().enumerate() {
390 if i != outer && contains(&loops[outer], ring[0]) {
391 let ((x, y), a) = measured[i];
392 sx -= x * a.abs();
393 sy -= y * a.abs();
394 sa -= a.abs();
395 }
396 }
397 if sa <= 0.0 {
398 return None;
399 }
400 Some(origin + e1 * (sx / sa) + e2 * (sy / sa))
401 }
402}
403
404fn area_centroid(ring: &[(f64, f64)]) -> ((f64, f64), f64) {
406 let (mut a, mut cx, mut cy) = (0.0, 0.0, 0.0);
407 for i in 0..ring.len() {
408 let (p, q) = (ring[i], ring[(i + 1) % ring.len()]);
409 let w = p.0 * q.1 - q.0 * p.1;
410 a += w;
411 cx += (p.0 + q.0) * w;
412 cy += (p.1 + q.1) * w;
413 }
414 a *= 0.5;
415 if a == 0.0 {
416 return (ring[0], 0.0);
417 }
418 ((cx / (6.0 * a), cy / (6.0 * a)), a)
419}
420
421fn contains(ring: &[(f64, f64)], p: (f64, f64)) -> bool {
423 let mut inside = false;
424 for i in 0..ring.len() {
425 let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
426 if (a.1 > p.1) != (b.1 > p.1) {
427 let x = a.0 + (p.1 - a.1) / (b.1 - a.1) * (b.0 - a.0);
428 if x > p.0 {
429 inside = !inside;
430 }
431 }
432 }
433 inside
434}