Files
OneCAD/crates/sketch/src/profiles.rs
T

1097 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
/// Curve ids of each hole loop.
pub hole_curves: Vec<Vec<String>>,
pub area: f64,
pub centroid: Vec2,
// Computed before exact-segment restoration; never trusted from serialized input.
#[serde(skip)]
complete_outer: Option<Vec<String>>,
#[serde(skip)]
complete_holes: Option<Vec<Vec<String>>>,
}
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<String>, Vec<Vec<String>>)> {
Some((self.complete_outer.clone()?, self.complete_holes.clone()?))
}
/// All curve ids bounding the profile.
pub fn curve_ids(&self) -> Vec<String> {
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<Piece>,
coverage: Option<Piece>,
}
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<usize, Vec<usize>>, sk: &Sketch) -> Option<Vec<String>> {
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::<usize, Vec<(usize, usize)>>::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::<Vec<_>>();
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<f64> {
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<Seg2>, tags: &[Option<(Piece, bool)>], exact: &std::collections::HashMap<usize, Vec<Seg2>>) -> Vec<Seg2> {
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<Run> = 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<usize> = (i..j).map(|x| (r + x) % n).collect();
let plain: Vec<Seg2> = 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<usize> = run.iter().filter_map(|x| tags.get(*x).copied().flatten().map(|((_, _, p, _), _)| p)).collect();
let want: Vec<usize> = 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<Seg2>),
/// `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<Seg2>,
},
}
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<Seg2> {
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<Vec<(i64, i64)>> {
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<Profile> {
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<Profile> {
find_profiles_bounded(sk, MAX_EDGES)
}
fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec<Profile> {
let tol = MERGE_TOL * 10.0;
// Analytic shapes of the profile curves.
let mut shapes: Vec<(usize, Shape)> = Vec::new();
let mut tags: Vec<Option<Piece>> = Vec::new();
let mut exact: std::collections::HashMap<usize, Vec<Seg2>> = std::collections::HashMap::new();
let mut whole_graph_complete = true;
let mut expected = std::collections::HashMap::<usize, Vec<usize>>::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<Vec2> = 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<f64>> = vec![Vec::new(); shapes.len()];
// End points of other curves lying on each shape.
let mut end_cells: std::collections::HashMap<(i64, i64), Vec<usize>> = 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<usize> = (0..ends.len()).collect();
for (i, (_, sh)) in shapes.iter().enumerate() {
let Some(b) = boxes.get(i) else { continue };
let cand: Vec<usize> = 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<usize>> = std::collections::HashMap::new();
let mut global: Vec<usize> = 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<usize>> = 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<Vec2> = Vec::new();
// Grid buckets of `tol`-sized cells: merging looks only at neighbouring cells.
let mut grid: std::collections::HashMap<(i64, i64), Vec<usize>> = 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<String>, usize, Option<Vec<String>>)> = Vec::new(); // (ccw loop, curve ids, component)
let mut edges: Vec<Edge> = 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<f64> =
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<usize> = (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<usize>> = 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<Option<(Piece, bool)>> = 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<f64> = 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<Option<usize>> = 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<usize>> = 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<Profile> = loops
.iter()
.enumerate()
.map(|(i, (l, ids, _, complete_outer))| {
let kids: Vec<usize> = kids_of.get(i).cloned().unwrap_or_default();
let holes: Vec<Loop2> = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _, _)| h.ccw().reversed())).collect();
let hole_curves: Vec<Vec<String>> = 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::<Option<Vec<_>>>();
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<Profile> = 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<Region2> {
let tol = MERGE_TOL * 10.0;
// Every boundary segment oriented with the region on its left.
let mut segs: Vec<Seg2> = 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<Seg2> = 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<Loop2> = 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<Region2> = 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());
}
}