//! Closed-profile detection. //! //! Non-construction lines and arcs form a planar graph (end points within a small tolerance are //! merged). Dangling edges are pruned, then faces are traced with the "first clockwise turn" //! rule, which yields every bounded region counter-clockwise. Circles are loops of their own. //! A loop that lies inside another loop of a different connected component becomes a hole of //! the innermost loop containing it, so a circle inside a rectangle gives two profiles: the //! rectangle with a hole, and the disc. use std::f64::consts::TAU; use serde::{Deserialize, Serialize}; use solvecraft_geom::{Loop2, Region2, Seg2, Vec2}; use crate::model::{CurveKind, Sketch}; use crate::shape::{Shape, intersections}; const MERGE_TOL: f64 = 1e-6; const MAX_EDGES: usize = 50_000; /// A closed region usable by features, with the ids of the curves bounding it. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Profile { pub region: Region2, /// Curve ids of the outer loop. pub outer_curves: Vec, /// Curve ids of each hole loop. pub hole_curves: Vec>, pub area: f64, pub centroid: Vec2, // Computed before exact-segment restoration; never trusted from serialized input. #[serde(skip)] complete_outer: Option>, #[serde(skip)] complete_holes: Option>>, } impl Profile { /// Whole source boundaries, certified by complete graph-piece coverage. Intersections, /// missing spans and unknown sampled geometry cannot acquire this persistent identity. pub fn whole_curve_boundaries(&self) -> Option<(Vec, Vec>)> { Some((self.complete_outer.clone()?, self.complete_holes.clone()?)) } /// All curve ids bounding the profile. pub fn curve_ids(&self) -> Vec { let mut v = self.outer_curves.clone(); for h in &self.hole_curves { v.extend(h.iter().cloned()); } v } } struct Edge { seg: Seg2, u: usize, v: usize, id: String, /// A piece of an exact free-form segment: (curve, segment, piece, pieces). tag: Option, coverage: Option, } type Piece = (usize, usize, usize, usize); /// Prove each represented source uses all its original spans exactly once and consistently /// in one direction. Work and storage are bounded by the existing graph-edge budget. fn complete_loop(tags: &[Option<(Piece, bool)>], expected: &std::collections::HashMap>, sk: &Sketch) -> Option> { use std::collections::{BTreeMap, BTreeSet}; if tags.is_empty() || tags.len() > MAX_EDGES { return None; } let mut seen = BTreeSet::new(); let mut directions = BTreeMap::new(); let mut counts = BTreeMap::>::new(); let mut previous_curve = tags.last().copied().flatten()?.0.0; let mut runs = BTreeSet::new(); for tag in tags { let ((ci, segment, piece, pieces), forward) = (*tag)?; let expected_pieces = *expected.get(&ci)?.get(segment)?; if pieces == 0 || pieces != expected_pieces || piece >= pieces || !seen.insert((ci, segment, piece)) { return None; } if directions.insert(ci, forward).is_some_and(|old| old != forward) { return None; } if previous_curve != ci && !runs.insert(ci) { return None; } previous_curve = ci; counts.entry(ci).or_default().push((segment, piece)); } let mut ids = Vec::with_capacity(counts.len()); for (ci, order) in counts { let spans = expected.get(&ci)?; let total = spans.iter().try_fold(0usize, |sum, n| sum.checked_add(*n))?; if spans.is_empty() || spans.contains(&0) || total > MAX_EDGES || total != order.len() { return None; } let mut offsets = Vec::with_capacity(spans.len()); let mut offset = 0; for count in spans { offsets.push(offset); offset += count; } let forward = *directions.get(&ci)?; let indices = order.iter().map(|(segment, piece)| offsets[*segment] + piece).collect::>(); if indices.windows(2).any(|w| w[1] != if forward { (w[0] + 1) % total } else { (w[0] + total - 1) % total }) { return None; } ids.push(sk.curves.get(ci)?.id.clone()); } ids.sort(); if ids.windows(2).any(|w| w[0] == w[1]) { return None; } Some(ids) } /// Parameter of `p` at an end of the shape (lines: 0 or 1), for intersections at end points. fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option { match *sh { Shape::Line { a, b } => { if p.dist(a) <= tol { Some(0.0) } else if p.dist(b) <= tol { Some(1.0) } else { None } } Shape::Round { c, r, start, sweep } => { let s0 = c + Vec2::from_angle(start) * r; let s1 = c + Vec2::from_angle(start + sweep) * r; (p.dist(s0) <= tol || p.dist(s1) <= tol).then_some(0.0) } } } /// Put exact free-form segments back. A loop that runs over all the pieces of one, in order, /// gets the whole segment; one that runs over part of it (cut by a crossing) gets the exact /// sub-segment, its ends moved to the true crossing with the neighbouring segment. Where that /// crossing cannot be found the loop keeps the polyline pieces. fn restore_exact(segs: Vec, tags: &[Option<(Piece, bool)>], exact: &std::collections::HashMap>) -> Vec { let n = segs.len(); if n == 0 || tags.len() != n || tags.iter().all(Option::is_none) { return segs; } let key = |i: usize| tags.get(i % n).copied().flatten().map(|((ci, k, _, _), fwd)| (ci, k, fwd)); // Start where a run begins so no run wraps around. let r = (0..n).find(|i| key(*i) != key(*i + n - 1)).unwrap_or(0); let mut runs: Vec = Vec::new(); let mut i = 0; while i < n { let k0 = key(r + i); let mut j = i + 1; while j < n && key(r + j) == k0 && k0.is_some() { j += 1; } let run: Vec = (i..j).map(|x| (r + x) % n).collect(); let plain: Vec = run.iter().filter_map(|x| segs.get(*x).copied()).collect(); let full = k0.and_then(|(ci, k, fwd)| exact.get(&ci).and_then(|e| e.get(k)).map(|s| if fwd { *s } else { s.reversed() })); let whole = match (full, tags.get(run[0]).copied().flatten()) { (Some(_), Some(((_, _, _, cnt), fwd))) if run.len() == cnt => { let order: Vec = run.iter().filter_map(|x| tags.get(*x).copied().flatten().map(|((_, _, p, _), _)| p)).collect(); let want: Vec = if fwd { (0..cnt).collect() } else { (0..cnt).rev().collect() }; order == want } _ => false, }; runs.push(match full { Some(seg) if whole => Run::Exact { seg, t0: 0.0, t1: 1.0, fixed: (true, true), plain }, Some(seg) => { let (a, b) = (plain.first().map(Seg2::start).unwrap_or_default(), plain.last().map(Seg2::end).unwrap_or_default()); let (t0, t1) = (seg.closest_param(a), seg.closest_param(b)); // Ends that are the segment's own ends need no search. let snap = |t: f64, p: Vec2, at: f64| (t - at).abs() < 1e-6 && seg.point_at(at).dist(p) < 1e-6; let fixed = (snap(t0, a, 0.0), snap(t1, b, 1.0)); let (t0, t1) = (if fixed.0 { 0.0 } else { t0 }, if fixed.1 { 1.0 } else { t1 }); if t1 - t0 > 1e-9 { Run::Exact { seg, t0, t1, fixed, plain } } else { Run::Plain(plain) } } None => Run::Plain(plain), }); i = j; } // Junctions next to a cut exact run: find the true crossing. let m = runs.len(); for a in 0..m { let b = (a + 1) % m; let need = match (runs.get(a), runs.get(b)) { (Some(Run::Exact { fixed, .. }), _) if !fixed.1 => true, (_, Some(Run::Exact { fixed, .. })) if !fixed.0 => true, _ => false, }; if !need { continue; } let (Some(ra), Some(rb)) = (runs.get(a), runs.get(b)) else { continue }; let p0 = ra.end(); let crossing = refine_crossing(ra, rb, p0); match crossing { Some((p, ta, tb)) if p.dist(p0) < 1e-2 => { if let Some(x) = runs.get_mut(a) { x.set_end(p, ta); } if let Some(x) = runs.get_mut(b) { x.set_start(p, tb); } } _ => { for k in [a, b] { if let Some(x) = runs.get_mut(k) { x.give_up(); } } } } } runs.into_iter().flat_map(Run::into_segs).collect() } /// A stretch of a profile loop: plain segments, or (part of) an exact free-form segment. enum Run { Plain(Vec), /// `seg` (in loop direction) between parameters `t0` and `t1`; `fixed` ends are exact; /// `plain` is the polyline fallback. Exact { seg: Seg2, t0: f64, t1: f64, fixed: (bool, bool), plain: Vec, }, } impl Run { fn end(&self) -> Vec2 { match self { Run::Plain(v) => v.last().map(Seg2::end).unwrap_or_default(), Run::Exact { seg, t1, .. } => seg.point_at(*t1), } } /// The segment at the run's end and the parameter of the end on it. fn end_geom(&self) -> Option<(Seg2, f64)> { match self { Run::Plain(v) => v.last().map(|s| (*s, 1.0)), Run::Exact { seg, t1, .. } => Some((*seg, *t1)), } } /// The segment at the run's start and the parameter of the start on it. fn start_geom(&self) -> Option<(Seg2, f64)> { match self { Run::Plain(v) => v.first().map(|s| (*s, 0.0)), Run::Exact { seg, t0, .. } => Some((*seg, *t0)), } } fn set_end(&mut self, p: Vec2, t: f64) { match self { Run::Plain(v) => { if let Some(s) = v.last_mut() { *s = with_end(*s, p); } } Run::Exact { t1, .. } => *t1 = t, } } fn set_start(&mut self, p: Vec2, t: f64) { match self { Run::Plain(v) => { if let Some(s) = v.first_mut() { *s = with_end(s.reversed(), p).reversed(); } } Run::Exact { t0, .. } => *t0 = t, } } fn give_up(&mut self) { if let Run::Exact { fixed: (false, _) | (_, false), plain, .. } = self { *self = Run::Plain(std::mem::take(plain)); } } fn into_segs(self) -> Vec { match self { Run::Plain(v) => v, Run::Exact { seg, t0, t1, .. } => { if t0 <= 1e-12 && t1 >= 1.0 - 1e-12 { return vec![seg]; } let s = if t1 < 1.0 - 1e-12 { seg.split_at(t1).0 } else { seg }; if t0 <= 1e-12 { return vec![s]; } // Conic halves are reparametrised: find t0's point again on the left part. let u = local_param(&s, seg.point_at(t0), if t1 > 1e-12 { t0 / t1 } else { 0.0 }); vec![s.split_at(u.clamp(1e-9, 1.0 - 1e-9)).1] } } } } /// The segment with its end moved to `p` (lines; arcs keep their circle). fn with_end(s: Seg2, p: Vec2) -> Seg2 { match s { Seg2::Line { a, .. } => Seg2::Line { a, b: p }, Seg2::Arc { center, radius, start, sweep } => { let target = (p - center).angle(); let end = start + sweep; // The same end angle, wound the same way. let delta = (target - end + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI; Seg2::Arc { center, radius, start, sweep: sweep + delta } } other => other, } } /// Where the end of run `a` meets the start of run `b`, near `p`: the point and the parameters /// on the two runs' segments (Newton on A(ta) = B(tb); lines and arcs extend past their ends). fn refine_crossing(a: &Run, b: &Run, p: Vec2) -> Option<(Vec2, f64, f64)> { let (sa, mut ta) = a.end_geom()?; let (sb, mut tb) = b.start_geom()?; // Start from the parameters nearest the polyline crossing. ta = local_param(&sa, p, ta); tb = local_param(&sb, p, tb); for _ in 0..50 { let f = sa.point_at(ta) - sb.point_at(tb); if f.len() < 1e-11 { let pt = match (a, b) { (Run::Exact { .. }, _) | (Run::Plain(_), Run::Plain(_)) => sa.point_at(ta), (_, Run::Exact { .. }) => sb.point_at(tb), }; return (ta.is_finite() && tb.is_finite()).then_some((pt, ta, tb)); } let (da, db) = (sa.derivative(ta), -sb.derivative(tb)); let det = da.cross(db); if det.abs() < 1e-14 * da.len().max(1e-300) * db.len().max(1e-300) { return None; } // Solve [da db] (dta, dtb)ᵀ = −f. ta -= f.cross(db) / det; tb -= da.cross(f) / det; } None } /// Parameter of the point of `s` nearest `p`, by Newton from `t`. fn local_param(s: &Seg2, p: Vec2, mut t: f64) -> f64 { for _ in 0..30 { let (q, d) = (s.point_at(t), s.derivative(t)); let dd = d.dot(d); if dd < 1e-300 { break; } let step = (p - q).dot(d) / dd; t += step; if step.abs() < 1e-15 { break; } } t } /// A uniform grid over a set of boxes (about one box per cell), for neighbour queries. struct Grid { lo: Vec2, size: f64, } impl Grid { fn new(boxes: &[(Vec2, Vec2)]) -> Grid { let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)); for (a, b) in boxes { lo = Vec2::new(lo.x.min(a.x), lo.y.min(a.y)); hi = Vec2::new(hi.x.max(b.x), hi.y.max(b.y)); } if !(lo.is_finite() && hi.is_finite()) { return Grid { lo: Vec2::ZERO, size: 1.0 }; } let ext = (hi.x - lo.x).max(hi.y - lo.y).max(1e-9); let per_side = (boxes.len() as f64).sqrt().ceil().clamp(1.0, 512.0); Grid { lo, size: ext / per_side } } fn cell(&self, p: Vec2) -> (i64, i64) { (((p.x - self.lo.x) / self.size).floor() as i64, ((p.y - self.lo.y) / self.size).floor() as i64) } /// Cells a box covers; None for boxes spanning too many (they are checked against all). fn cells(&self, b: &(Vec2, Vec2)) -> Option> { let (c0, c1) = (self.cell(b.0), self.cell(b.1)); if (c1.0 - c0.0 + 1).saturating_mul(c1.1 - c0.1 + 1) > 2048 { return None; } let mut out = Vec::new(); for x in c0.0..=c1.0 { for y in c0.1..=c1.1 { out.push((x, y)); } } Some(out) } } fn find(p: &mut [usize], mut i: usize) -> usize { while let Some(&q) = p.get(i) { if q == i { break; } i = q; } i } /// Signed curvature at the start of a segment (left turns positive). fn curvature(s: &Seg2) -> f64 { match *s { Seg2::Line { .. } => 0.0, Seg2::Arc { radius, sweep, .. } => sweep.signum() / radius.max(1e-12), Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => { let (d1, h) = (s.derivative(0.0), 1e-5); let d2 = (s.derivative(h) - d1) / h; d1.cross(d2) / d1.len().powi(3).max(1e-300) } } } /// Find all closed profiles of a sketch. Curves are split where other curves end on them or /// cross them, like a drawing would be read. /// Profiles of the drawn geometry only (linked reference curves left out). pub fn find_drawn_profiles(sk: &Sketch) -> Vec { if sk.links.is_empty() { return find_profiles(sk); } let mut d = sk.clone(); for c in &mut d.curves { if c.link.is_some() { c.construction = true; } } find_profiles(&d) } pub fn find_profiles(sk: &Sketch) -> Vec { find_profiles_bounded(sk, MAX_EDGES) } fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let tol = MERGE_TOL * 10.0; // Analytic shapes of the profile curves. let mut shapes: Vec<(usize, Shape)> = Vec::new(); let mut tags: Vec> = Vec::new(); let mut exact: std::collections::HashMap> = std::collections::HashMap::new(); let mut whole_graph_complete = true; let mut expected = std::collections::HashMap::>::new(); for (ci, c) in sk.curves.iter().enumerate() { if c.construction || c.centerline { continue; } if matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. } | CurveKind::Circle { .. }) { expected.insert(ci, vec![1]); } match c.kind { CurveKind::Line { a, b } => { if let (Some(a), Some(b)) = (sk.point(a), sk.point(b)) && a.dist(b) > MERGE_TOL { shapes.push((ci, Shape::Line { a, b })); } } CurveKind::Circle { c, r } => { if let Some(c) = sk.point(c) { shapes.push((ci, Shape::Round { c, r, start: 0.0, sweep: TAU })); } } CurveKind::Arc { .. } => { if let Some(Seg2::Arc { center, radius, start, sweep }) = sk.segs(ci).into_iter().next() { shapes.push((ci, Shape::Round { c: center, r: radius, start, sweep })); } } // Free-form curves take part as polylines of their exact segments; loops that use // a whole segment get the exact one back. _ => match sk.exact_segs(ci) { Some(ex) => { let mut spans = Vec::with_capacity(ex.len()); for (k, seg) in ex.iter().enumerate() { let poly = seg.polyline(1e-3); let n = poly.len().saturating_sub(1); spans.push(n); for (j, w) in poly.windows(2).enumerate() { if w[0].dist(w[1]) > MERGE_TOL { shapes.push((ci, Shape::Line { a: w[0], b: w[1] })); tags.resize(shapes.len() - 1, None); tags.push(Some((ci, k, j, n))); } } } expected.insert(ci, spans); exact.insert(ci, ex); } None => { for s in sk.segs(ci) { if let Seg2::Line { a, b } = s { shapes.push((ci, Shape::Line { a, b })); } } } }, } tags.resize(shapes.len(), None); if shapes.len() > max_edges { whole_graph_complete = false; break; } } // Split parameters per shape: end points of other curves on it, and crossings. let mut ends: Vec = Vec::new(); for (_, sh) in &shapes { match *sh { Shape::Line { a, b } => ends.extend([a, b]), Shape::Round { sweep, .. } if sweep < TAU - 1e-12 => ends.extend([sh.point(0.0), sh.point(sweep)]), Shape::Round { .. } => {} } } // Bounding boxes, and a uniform grid over them: only shapes sharing a cell can meet. let boxes: Vec<(Vec2, Vec2)> = shapes .iter() .map(|(_, sh)| match *sh { Shape::Line { a, b } => (Vec2::new(a.x.min(b.x) - tol, a.y.min(b.y) - tol), Vec2::new(a.x.max(b.x) + tol, a.y.max(b.y) + tol)), Shape::Round { c, r, .. } => (Vec2::new(c.x - r - tol, c.y - r - tol), Vec2::new(c.x + r + tol, c.y + r + tol)), }) .collect(); let overlap = |i: usize, j: usize| match (boxes.get(i), boxes.get(j)) { (Some((a0, a1)), Some((b0, b1))) => a0.x <= b1.x && b0.x <= a1.x && a0.y <= b1.y && b0.y <= a1.y, _ => false, }; let grid = Grid::new(&boxes); let mut cuts: Vec> = vec![Vec::new(); shapes.len()]; // End points of other curves lying on each shape. let mut end_cells: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); for (k, p) in ends.iter().enumerate() { end_cells.entry(grid.cell(*p)).or_default().push(k); } let all_ends: Vec = (0..ends.len()).collect(); for (i, (_, sh)) in shapes.iter().enumerate() { let Some(b) = boxes.get(i) else { continue }; let cand: Vec = match grid.cells(b) { Some(cs) => cs.iter().flat_map(|c| end_cells.get(c).map(Vec::as_slice).unwrap_or(&[])).copied().collect(), None => all_ends.clone(), }; for k in cand { if let Some(p) = ends.get(k) && let Some(t) = sh.param_on(*p, tol) && let Some(cu) = cuts.get_mut(i) { cu.push(t); } } } let mut shape_cells: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); let mut global: Vec = Vec::new(); for (i, b) in boxes.iter().enumerate() { match grid.cells(b) { Some(cs) => { for c in cs { shape_cells.entry(c).or_default().push(i); } } None => global.push(i), } } let mut partners: Vec> = vec![Vec::new(); shapes.len()]; for v in shape_cells.values() { for (k, &i) in v.iter().enumerate() { for &j in v.iter().skip(k + 1) { let (lo, hi) = if i < j { (i, j) } else { (j, i) }; if let Some(p) = partners.get_mut(lo) { p.push(hi); } } } } for &g in &global { for j in 0..shapes.len() { if j != g { let (lo, hi) = if g < j { (g, j) } else { (j, g) }; if let Some(p) = partners.get_mut(lo) { p.push(hi); } } } } for p in &mut partners { p.sort_unstable(); p.dedup(); } if shapes.len() <= 50_000 { for i in 0..shapes.len() { for &j in partners.get(i).map(Vec::as_slice).unwrap_or(&[]) { if !overlap(i, j) { continue; } let (Some((_, x)), Some((_, y))) = (shapes.get(i), shapes.get(j)) else { continue }; let mut pts = intersections(x, y); // A tangent touch gives two nearly equal roots: one point. if let [p, q] = pts[..] && p.dist(q) < tol * 100.0 { pts = vec![(p + q) * 0.5]; } for p in pts { let on_x = x.param_on(p, tol).or_else(|| end_param(x, p, tol)); let on_y = y.param_on(p, tol).or_else(|| end_param(y, p, tol)); // Crossings at (or next to) curve ends are already cuts. let near_end = || { let (cx, cy) = grid.cell(p); (-1..=1).any(|dx| { (-1..=1).any(|dy| { end_cells .get(&(cx + dx, cy + dy)) .is_some_and(|v| v.iter().any(|k| ends.get(*k).is_some_and(|e| e.dist(p) < tol * 100.0))) }) }) }; if on_x.is_none() || on_y.is_none() || near_end() { continue; } if let (Some(tx), Some(ty)) = (on_x, on_y) { if x.param_on(p, tol).is_some() && let Some(c) = cuts.get_mut(i) { c.push(tx); } if y.param_on(p, tol).is_some() && let Some(c) = cuts.get_mut(j) { c.push(ty); } } } } } } // Pieces → edges between merged nodes. let mut nodes: Vec = Vec::new(); // Grid buckets of `tol`-sized cells: merging looks only at neighbouring cells. let mut grid: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); let cell = |p: Vec2| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64); let mut node = |p: Vec2| -> usize { let (cx, cy) = cell(p); for dx in -1..=1 { for dy in -1..=1 { if let Some(v) = grid.get(&(cx + dx, cy + dy)) && let Some(i) = v.iter().copied().find(|i| nodes.get(*i).is_some_and(|q| q.dist(p) <= tol)) { return i; } } } nodes.push(p); let i = nodes.len() - 1; grid.entry((cx, cy)).or_default().push(i); i }; let mut loops: Vec<(Loop2, Vec, usize, Option>)> = Vec::new(); // (ccw loop, curve ids, component) let mut edges: Vec = Vec::new(); for (k, (ci, sh)) in shapes.iter().enumerate() { let mut ts = cuts.get(k).cloned().unwrap_or_default(); ts.sort_by(f64::total_cmp); // Cuts closer than a few tolerances are one cut (tangent touches give near-double roots). let scale = match *sh { Shape::Line { a, b } => a.dist(b), Shape::Round { r, .. } => r, }; let close = (tol * 100.0 / scale.max(1e-12)).max(1e-9); ts.dedup_by(|a, b| (*a - *b).abs() < close); if let (Shape::Round { sweep, .. }, Some(&first), Some(&last)) = (*sh, ts.first(), ts.last()) && sweep >= TAU - 1e-12 && ts.len() > 1 && (first + TAU - last).abs() < close { ts.pop(); } let id = sk.curves.get(*ci).map(|c| c.id.clone()).unwrap_or_default(); let coverage = tags.get(k).copied().flatten().or_else(|| { sk.curves .get(*ci) .filter(|c| matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. } | CurveKind::Circle { .. })) .map(|_| (*ci, 0, 0, 1)) }); let coverage = if ts.is_empty() { coverage } else { None }; match *sh { Shape::Line { a, b } => { let mut ps = vec![a]; ps.extend(ts.iter().map(|t| sh.point(*t))); ps.push(b); // Pieces split by other curves no longer stand for their whole piece. let tag = if ts.is_empty() { tags.get(k).copied().flatten() } else { None }; for w in ps.windows(2) { let (p, q) = (w[0], w[1]); if p.dist(q) > MERGE_TOL { let (u, v) = (node(p), node(q)); edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone(), tag, coverage }); } } } Shape::Round { c, r, start, sweep } => { let full = sweep >= TAU - 1e-12; if full && ts.is_empty() { loops.push((Loop2::circle(c, r), vec![id.clone()], usize::MAX - *ci, whole_graph_complete.then(|| vec![id.clone()]))); continue; } let mut bounds: Vec = if full { ts.clone() } else { std::iter::once(0.0).chain(ts.iter().copied()).chain(std::iter::once(sweep)).collect() }; if full { let first = bounds.first().copied().unwrap_or(0.0); bounds.push(first + TAU); } for w in bounds.windows(2) { let (t0, t1) = (w[0], w[1]); if (t1 - t0) * r <= MERGE_TOL { continue; } let seg = Seg2::Arc { center: c, radius: r, start: start + t0, sweep: t1 - t0 }; let (u, v) = (node(seg.start()), node(seg.end())); edges.push(Edge { seg, u, v, id: id.clone(), tag: None, coverage }); } } } } let n = nodes.len(); // Prune dangling edges. let mut alive = vec![true; edges.len()]; loop { let mut deg = vec![0usize; n]; for (e, a) in edges.iter().zip(&alive) { if *a { if let Some(d) = deg.get_mut(e.u) { *d += 1; } if let Some(d) = deg.get_mut(e.v) { *d += 1; } } } let mut changed = false; for (e, a) in edges.iter().zip(alive.iter_mut()) { if *a && (deg.get(e.u).copied().unwrap_or(0) < 2 || deg.get(e.v).copied().unwrap_or(0) < 2) { *a = false; changed = true; } } if !changed { break; } } // Components over nodes for nesting. let mut comp: Vec = (0..n).collect(); for (e, a) in edges.iter().zip(&alive) { if *a { let (ru, rv) = (find(&mut comp, e.u), find(&mut comp, e.v)); if ru != rv && let Some(s) = comp.get_mut(rv) { *s = ru; } } } // Half-edges: 2e = forward (u→v), 2e+1 = backward. let half = |h: usize| -> Option<(Seg2, usize, usize)> { let e = edges.get(h / 2)?; Some(if h.is_multiple_of(2) { (e.seg, e.u, e.v) } else { (e.seg.reversed(), e.v, e.u) }) }; let mut outgoing: Vec> = vec![Vec::new(); n]; for (ei, a) in alive.iter().enumerate() { if !*a { continue; } for h in [2 * ei, 2 * ei + 1] { if let Some((_, from, _)) = half(h) && let Some(o) = outgoing.get_mut(from) { o.push(h); } } } let mut visited = vec![false; edges.len() * 2]; for start in 0..edges.len() * 2 { if !alive.get(start / 2).copied().unwrap_or(false) || visited.get(start).copied().unwrap_or(true) { continue; } let mut segs = Vec::new(); let mut ids = Vec::new(); let mut ptags: Vec> = Vec::new(); let mut coverage_tags = Vec::new(); let mut h = start; let mut ok = true; for _ in 0..=edges.len() * 2 { if let Some(v) = visited.get_mut(h) { *v = true; } let Some((seg, _, to)) = half(h) else { ok = false; break; }; segs.push(seg); if let Some(e) = edges.get(h / 2) { ids.push(e.id.clone()); ptags.push(e.tag.map(|t| (t, h.is_multiple_of(2)))); coverage_tags.push(e.coverage.map(|t| (t, h.is_multiple_of(2)))); } else { ptags.push(None); coverage_tags.push(None); } let back = (-seg.end_tangent()).angle(); let twin = h ^ 1; // First clockwise turn from the way back. Directions are compared a short way // along each segment (curvature decides between equal tangents, e.g. a line tangent // to an arc, or an arc leaving back along the incoming line). let kt = half(twin).map(|(ts, _, _)| curvature(&ts)).unwrap_or(0.0); let mut best: Option<(f64, f64, usize)> = None; for &o in outgoing.get(to).map(Vec::as_slice).unwrap_or(&[]) { if o == twin { continue; } let Some((os, _, _)) = half(o) else { continue }; let mut raw = (back - os.start_tangent().angle()).rem_euclid(TAU); if raw > TAU - 1e-9 { raw = 0.0; } let mut d = raw + (kt - curvature(&os)) * 1e-7; if d <= 1e-12 { d += TAU; } if best.is_none_or(|(bd, _, _)| d < bd) { best = Some((d, 0.0, o)); } } let best = best.map(|(d, _, o)| (d, o)); let Some((_, next)) = best else { ok = false; break; }; h = next; if h == start { break; } if visited.get(h).copied().unwrap_or(true) { ok = false; break; } } if !ok || h != start { continue; } let lp = Loop2 { segs: restore_exact(segs, &ptags, &exact) }; if lp.signed_area() > 1e-9 { let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0); loops.push((lp, ids, c, whole_graph_complete.then(|| complete_loop(&coverage_tags, &expected, sk)).flatten())); } } // Nesting: parent = smallest containing loop from another component. Bounding boxes rule // out most pairs before the point-in-loop test. let areas: Vec = loops.iter().map(|(l, _, _, _)| l.signed_area().abs()).collect(); let boxes: Vec<(Vec2, Vec2)> = loops .iter() .map(|(l, _, _, _)| { l.polyline(1e-3).iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| { (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y))) }) }) .collect(); let inside_box = |j: usize, p: Vec2| boxes.get(j).is_some_and(|(lo, hi)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y); let mut parent_of: Vec> = vec![None; loops.len()]; for (i, (li, _, ci, _)) in loops.iter().enumerate() { let probe = li.segs.first().map(Seg2::mid).unwrap_or_default(); let ai = areas.get(i).copied().unwrap_or(0.0); let mut best: Option<(f64, usize)> = None; for (j, (lj, _, cj, _)) in loops.iter().enumerate() { let aj = areas.get(j).copied().unwrap_or(0.0); if i == j || ci == cj || aj <= ai || !inside_box(j, probe) || best.is_some_and(|(ba, _)| aj >= ba) { continue; } if lj.contains(probe) { best = Some((aj, j)); } } if let Some(p) = parent_of.get_mut(i) { *p = best.map(|(_, j)| j); } } let mut kids_of: Vec> = vec![Vec::new(); loops.len()]; for (k, p) in parent_of.iter().enumerate() { if let Some(p) = p && let Some(v) = kids_of.get_mut(*p) { v.push(k); } } let out: Vec = loops .iter() .enumerate() .map(|(i, (l, ids, _, complete_outer))| { let kids: Vec = kids_of.get(i).cloned().unwrap_or_default(); let holes: Vec = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _, _)| h.ccw().reversed())).collect(); let hole_curves: Vec> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _, _)| ids.clone())).collect(); let region = Region2 { outer: l.ccw(), holes }; let area = region.area(); let centroid = region.centroid(); let mut complete_holes = kids.iter().map(|k| loops.get(*k)?.3.clone()).collect::>>(); if let Some(holes) = &mut complete_holes { holes.sort(); } Profile { region, outer_curves: ids.clone(), hole_curves, area, centroid, complete_outer: complete_outer.clone(), complete_holes } }) .collect(); let index: std::collections::HashMap<&str, usize> = sk.curves.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect(); let first_curve = |p: &Profile| p.outer_curves.iter().filter_map(|id| index.get(id.as_str()).copied()).min().unwrap_or(usize::MAX); let mut keyed: Vec<(usize, Profile)> = out.into_iter().map(|p| (first_curve(&p), p)).collect(); keyed.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.area.total_cmp(&a.1.area))); let out: Vec = keyed.into_iter().map(|x| x.1).collect(); out } fn same_seg(a: &Seg2, b: &Seg2, tol: f64) -> bool { match (*a, *b) { (Seg2::Line { a: p, b: q }, Seg2::Line { a: r, b: s }) => p.dist(r) <= tol && q.dist(s) <= tol, (Seg2::Arc { center: c1, radius: r1, .. }, Seg2::Arc { center: c2, radius: r2, .. }) => { c1.dist(c2) <= tol && (r1 - r2).abs() <= tol && a.start().dist(b.start()) <= tol && a.end().dist(b.end()) <= tol && a.mid().dist(b.mid()) <= tol } _ => false, } } /// Union of regions that touch along shared boundary segments (one extrude of several adjacent /// profiles makes one body). Disjoint regions stay separate. pub fn merge_regions(regions: &[Region2]) -> Vec { let tol = MERGE_TOL * 10.0; // Every boundary segment oriented with the region on its left. let mut segs: Vec = Vec::new(); for r in regions { segs.extend(r.outer.ccw().segs); for h in &r.holes { segs.extend(h.ccw().reversed().segs); } } if segs.len() > MAX_EDGES { return regions.to_vec(); } // Drop pairs that run against each other (shared boundaries). let mut alive = vec![true; segs.len()]; let mut shared = false; for i in 0..segs.len() { if !alive.get(i).copied().unwrap_or(false) { continue; } for j in (i + 1)..segs.len() { if alive.get(j).copied().unwrap_or(false) && let (Some(a), Some(b)) = (segs.get(i), segs.get(j)) && same_seg(a, &b.reversed(), tol) { alive[i] = false; alive[j] = false; shared = true; break; } } } if !shared { return regions.to_vec(); } let rest: Vec = segs.iter().zip(&alive).filter(|(_, a)| **a).map(|(s, _)| *s).collect(); // Chain into loops, taking the sharpest left turn where several continue. let mut used = vec![false; rest.len()]; let mut loops: Vec = Vec::new(); for start in 0..rest.len() { if used.get(start).copied().unwrap_or(true) { continue; } let mut lp = Vec::new(); let mut cur = start; for _ in 0..=rest.len() { if let Some(u) = used.get_mut(cur) { *u = true; } let Some(seg) = rest.get(cur).copied() else { break }; lp.push(seg); let end = seg.end(); if let Some(first) = rest.get(start) && end.dist(first.start()) <= tol && lp.len() > 1 { break; } let back = (-seg.end_tangent()).angle(); let next = (0..rest.len()) .filter(|k| !used.get(*k).copied().unwrap_or(true) && rest.get(*k).is_some_and(|s| s.start().dist(end) <= tol)) .min_by(|x, y| { let d = |k: usize| rest.get(k).map(|s| (back - s.start_tangent().angle()).rem_euclid(TAU)).unwrap_or(TAU); d(*x).total_cmp(&d(*y)) }); match next { Some(k) => cur = k, None => break, } } if !lp.is_empty() && lp.first().zip(lp.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= tol) { loops.push(Loop2 { segs: lp }); } } let outers: Vec<&Loop2> = loops.iter().filter(|l| l.signed_area() > 0.0).collect(); let holes: Vec<&Loop2> = loops.iter().filter(|l| l.signed_area() < 0.0).collect(); let mut out: Vec = outers.iter().map(|o| Region2 { outer: (*o).clone(), holes: Vec::new() }).collect(); for h in holes { let probe = h.segs.first().map(Seg2::mid).unwrap_or_default(); // Innermost containing outer loop. let best = out.iter_mut().filter(|r| r.outer.contains(probe)).min_by(|a, b| a.outer.signed_area().total_cmp(&b.outer.signed_area())); if let Some(r) = best { r.holes.push(h.clone()); } } if out.is_empty() { regions.to_vec() } else { out } } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::panic)] mod whole_boundary_tests { use super::*; #[test] fn whole_boundary_coverage_requires_every_piece_once_in_one_direction() { let mut sk = Sketch::new(); sk.add_circle(Vec2::ZERO, 5.0, None, Some("c1")).unwrap(); let expected = std::collections::HashMap::from([(0, vec![2, 3])]); let full = vec![ Some(((0, 0, 0, 2), true)), Some(((0, 0, 1, 2), true)), Some(((0, 1, 0, 3), true)), Some(((0, 1, 1, 3), true)), Some(((0, 1, 2, 3), true)), ]; assert_eq!(complete_loop(&full, &expected, &sk), Some(vec!["c1".into()])); assert!(complete_loop(&full[..4], &expected, &sk).is_none()); let mut duplicate = full.clone(); duplicate[4] = duplicate[3]; assert!(complete_loop(&duplicate, &expected, &sk).is_none()); let mut cut = full.clone(); cut[2] = None; assert!(complete_loop(&cut, &expected, &sk).is_none()); let mut permuted = full.clone(); permuted.swap(2, 3); assert!(complete_loop(&permuted, &expected, &sk).is_none()); assert!(complete_loop(&full, &std::collections::HashMap::new(), &sk).is_none()); // A skipped tiny graph span still belongs to the original curve and cannot be ignored. let omitted = std::collections::HashMap::from([(0, vec![2, 4])]); assert!(complete_loop(&full, &omitted, &sk).is_none()); let mut reverse = full; reverse[2] = Some(((0, 1, 0, 3), false)); assert!(complete_loop(&reverse, &expected, &sk).is_none()); } #[test] fn whole_boundary_refuses_globally_truncated_graph_context() { let mut sk = Sketch::new(); sk.add_circle(Vec2::ZERO, 10.0, None, Some("outline")).unwrap(); sk.add_circle(Vec2::ZERO, 5.0, None, Some("hole")).unwrap(); sk.add_line(Vec2::new(-12.0, 0.0), Vec2::new(12.0, 0.0), None, None, Some("later-crossing")).unwrap(); // Exercise the production truncation branch with a small deterministic budget; // the old display still has outlines, but the omitted crossing forbids persistence. let displayed = find_profiles_bounded(&sk, 1); assert_eq!(displayed.len(), 2); assert!(displayed.iter().all(|p| p.whole_curve_boundaries().is_none())); } #[test] fn whole_boundary_records_holes_but_refuses_intersection_fragments() { let mut sk = Sketch::new(); sk.add_circle(Vec2::ZERO, 10.0, None, Some("outer")).unwrap(); sk.add_circle(Vec2::ZERO, 5.0, None, Some("hole")).unwrap(); let profiles = find_profiles(&sk); let annulus = profiles.iter().find(|p| !p.region.holes.is_empty()).unwrap(); assert_eq!(annulus.whole_curve_boundaries(), Some((vec!["outer".into()], vec![vec!["hole".into()]]))); // A line through the ring creates bounded fragments of both original circles. sk.add_line(Vec2::new(-12.0, 0.0), Vec2::new(12.0, 0.0), None, None, Some("cut")).unwrap(); let profiles = find_profiles(&sk); assert!(!profiles.is_empty()); assert!(profiles.iter().all(|p| p.whole_curve_boundaries().is_none())); // Serialized profiles cannot forge this runtime certificate. let copy: Profile = serde_json::from_str(&serde_json::to_string(annulus).unwrap()).unwrap(); assert!(copy.whole_curve_boundaries().is_none()); } }