//! Conservative analytic boundary roles for associative face identity. //! Endpoint walks exclude support centres. Crossings/touches refuse before nesting. use super::{LCurve, curve_points}; use crate::{Shape, intersections}; use solidcraft_geom::Vec2; use std::f64::consts::TAU; struct Boundary { curves: Vec, shapes: Vec, lo: Vec2, hi: Vec2, depth: usize, signature: [usize; 3], } pub(super) struct Roles { pub old: Vec>, pub fresh: Vec>, pub old_normal: Vec, pub fresh_normal: Vec, pub old_lines: Vec>, pub fresh_lines: Vec>, } pub(super) fn same_lines(a: &[Vec2], b: &[Vec2]) -> bool { a.len() == b.len() && a.iter().all(|x| b.iter().any(|y| x.dot(*y).abs() > 0.98)) } fn on(s: &Shape, p: Vec2, tol: f64) -> bool { match *s { Shape::Line { a, b } => { let d = b - a; let t = (p - a).dot(d) / d.len2().max(1e-30); t >= -tol / d.len().max(tol) && t <= 1.0 + tol / d.len().max(tol) && (a + d * t).dist(p) <= tol } Shape::Round { c, r, start, sweep } => { (p.dist(c) - r).abs() <= tol && (sweep >= TAU - 1e-12 || ((p - c).angle() - start).rem_euclid(TAU) <= sweep + tol / r) } } } fn endpoints(s: &Shape) -> [Vec2; 2] { let (_, end) = s.range(); [s.point(0.0), s.point(end)] } fn stable_intersections(a: &Shape, b: &Shape, tol: f64) -> Vec { match (*a, *b) { (Shape::Line { a, b }, Shape::Round { c, r, .. }) | (Shape::Round { c, r, .. }, Shape::Line { a, b }) => { let Some(direction) = (b - a).normalized() else { return Vec::new() }; // Work in the circle's local orthonormal frame. The world-coordinate // quadratic discriminant can lose every significant bit on long lines. let foot = a + direction * (c - a).dot(direction); let distance = foot.dist(c); if distance > r + tol { return Vec::new(); } let half = ((r - distance).max(0.0) * (r + distance)).sqrt(); vec![foot - direction * half, foot + direction * half] } (Shape::Round { c: ac, r: ar, .. }, Shape::Round { c: bc, r: br, .. }) => { let ((small, r), (large, radius)) = if ar <= br { ((ac, ar), (bc, br)) } else { ((bc, br), (ac, ar)) }; let delta = large - small; let distance = delta.len(); if distance <= tol || distance > radius + r + tol || distance < radius - r - tol { return Vec::new(); } let direction = delta / distance; // Compute from the smaller circle; factoring d^2-R^2 avoids the // cancellation of R^2-a^2 for a small hole crossing a huge circle. let along = ((distance - radius) * (distance + radius) + r * r) / (2.0 * distance); let height = ((r - along).max(0.0) * (r + along).max(0.0)).sqrt(); let foot = small + direction * along; vec![foot - direction.perp() * height, foot + direction.perp() * height] } _ => intersections(a, b), } } fn clear(a: &Shape, b: &Shape, shared: &[Vec2], tol: f64) -> bool { let allowed = |p: Vec2| { shared.iter().any(|q| { let joint_tol = 1e-7 + 128.0 * f64::EPSILON * (1.0 + p.len().max(q.len())); q.dist(p) <= joint_tol }) }; // Parallel coincident lines/circles have no isolated intersections. // Interior overlap is nevertheless ambiguous, including duplicate boundaries. for s in [a, b] { let other = if std::ptr::eq(s, a) { b } else { a }; let (_, end) = s.range(); for t in [0.0, 0.5 * end, end] { let p = s.point(t); if on(other, p, tol) && !allowed(p) { return false; } } } stable_intersections(a, b, tol).into_iter().all(|p| !on(a, p, tol) || !on(b, p, tol) || allowed(p)) } fn contains(boundary: &Boundary, p: Vec2, tol: f64) -> Option { let mut crossings = 0; for shape in &boundary.shapes { // Avoid ray/vertex coincidences rather than guessing a half-open arc rule. if endpoints(shape).iter().any(|q| (q.y - p.y).abs() <= tol) { return None; } match *shape { Shape::Line { a, b } => { if (a.y > p.y) != (b.y > p.y) { let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y); if (x - p.x).abs() <= tol { return None; } crossings += usize::from(x > p.x); } } Shape::Round { c, r, start, sweep } => { let y = (p.y - c.y) / r; if (y.abs() - 1.0).abs() <= tol / r { return None; } if y.abs() < 1.0 { let angle = y.asin(); for theta in [angle, std::f64::consts::PI - angle] { let off = (theta - start).rem_euclid(TAU); if off < sweep { let x = c.x + r * theta.cos(); if (x - p.x).abs() <= tol { return None; } crossings += usize::from(x > p.x); } } } } } } Some(crossings % 2 == 1) } fn boundaries(curves: &[LCurve], pts: &[Vec2]) -> Option> { let get = |i| pts.get(i).copied(); let mut shapes = Vec::new(); let mut ends = Vec::new(); let mut incidence = vec![Vec::new(); pts.len()]; for (i, curve) in curves.iter().enumerate() { let (shape, ep) = match *curve { LCurve::Line(a, b) => (Shape::Line { a: get(a)?, b: get(b)? }, Some((a, b))), LCurve::Arc(c, a, b) => { let (c, a_pos, b_pos) = (get(c)?, get(a)?, get(b)?); let sweep = ((b_pos - c).angle() - (a_pos - c).angle()).rem_euclid(TAU); (Shape::Round { c, r: c.dist(a_pos), start: (a_pos - c).angle(), sweep }, Some((a, b))) } LCurve::Circle(c, r) => (Shape::Round { c: get(c)?, r, start: 0.0, sweep: TAU }, None), // Existing free-form identity matching remains conservative and unchanged. _ => return None, }; if let Some((a, b)) = ep { incidence.get_mut(a)?.push(i); incidence.get_mut(b)?.push(i); } shapes.push(shape); ends.push(ep); } if incidence.iter().any(|v| !v.is_empty() && v.len() != 2) { return None; } let mut visited = vec![false; curves.len()]; let mut out = Vec::new(); for first in 0..curves.len() { if *visited.get(first)? { continue; } let mut members = Vec::new(); let mut pending = vec![first]; while let Some(i) = pending.pop() { if *visited.get(i)? { continue; } *visited.get_mut(i)? = true; members.push(i); if let Some((a, b)) = *ends.get(i)? { pending.extend(incidence.get(a)?); pending.extend(incidence.get(b)?); } } let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY); let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY); let mut signature = [0; 3]; let mut bound_shapes = Vec::new(); for i in &members { let shape = *shapes.get(*i)?; let mut candidates = endpoints(&shape).to_vec(); if let Shape::Round { c, r, .. } = shape { for angle in [0.0, std::f64::consts::FRAC_PI_2, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2] { let p = c + Vec2::from_angle(angle) * r; if on(&shape, p, 1e-7) { candidates.push(p); } } } for p in candidates { lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); } signature[match curves.get(*i)? { LCurve::Line(..) => 0, LCurve::Arc(..) => 1, _ => 2, }] += 1; bound_shapes.push(shape); } out.push(Boundary { curves: members, shapes: bound_shapes, lo, hi, depth: 0, signature }); } let scale = out.iter().map(|b| (b.hi - b.lo).len()).fold(1.0, f64::max); let tol = (scale * 1e-10).max(1e-7); let mut work = 0usize; for i in 0..out.len() { for j in i..out.len() { for (a_i, a) in out.get(i)?.shapes.iter().enumerate() { for (b_i, b) in out.get(j)?.shapes.iter().enumerate() { if i == j && a_i >= b_i { continue; } work += 1; if work > 262_144 { return None; } let mut shared = Vec::new(); if i == j { let ai = *out.get(i)?.curves.get(a_i)?; let bi = *out.get(j)?.curves.get(b_i)?; if let (Some((a0, a1)), Some((b0, b1))) = (*ends.get(ai)?, *ends.get(bi)?) { for point in [a0, a1] { if point == b0 || point == b1 { shared.push(get(point)?); } } } } if !clear(a, b, &shared, tol) { return None; } } } } } for i in 0..out.len() { let mut depth = 0; for j in 0..out.len() { if i == j { continue; } let shape = out.get(i)?.shapes.first()?; let (_, end) = shape.range(); let inside = [0.317, 0.613, 0.823].into_iter().find_map(|t| contains(out.get(j)?, shape.point(end * t), tol))?; depth += usize::from(inside); } out.get_mut(i)?.depth = depth; } Some(out) } pub(super) fn valid(curves: &[LCurve], points: &[Vec2]) -> bool { boundaries(curves, points).is_some() } fn normalize(p: Vec2, lo: Vec2, hi: Vec2) -> Vec2 { Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9)) } pub(super) fn roles(old_curves: &[LCurve], a: &[Vec2], fresh_curves: &[LCurve], b: &[Vec2]) -> Option { let (previous, incoming) = (boundaries(old_curves, a)?, boundaries(fresh_curves, b)?); if previous.len() != incoming.len() { return None; } let bounds = |loops: &[Boundary]| { loops.iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), x| { (Vec2::new(lo.x.min(x.lo.x), lo.y.min(x.lo.y)), Vec2::new(hi.x.max(x.hi.x), hi.y.max(x.hi.y))) }) }; let ((alo, ahi), (blo, bhi)) = (bounds(&previous), bounds(&incoming)); let mut mapping = Vec::new(); for x in &previous { let mut candidates: Vec<_> = incoming .iter() .enumerate() .filter(|(_, y)| x.depth == y.depth && x.signature == y.signature) .map(|(i, y)| (i, normalize((x.lo + x.hi) * 0.5, alo, ahi).dist(normalize((y.lo + y.hi) * 0.5, blo, bhi)))) .collect(); candidates.sort_by(|x, y| x.1.total_cmp(&y.1)); let (i, d) = *candidates.first()?; if mapping.contains(&i) || (candidates.len() > 1 && (d >= 0.1 || candidates.get(1).is_some_and(|(_, second)| d >= second * 0.5))) { return None; } let y = incoming.get(i)?; // Preserve the conservative bound on relative boundary changes. This does // not mistake a stationary hole's coordinates for the outer loop's scaling. let old_size = normalize(x.hi, alo, ahi) - normalize(x.lo, alo, ahi); let new_size = normalize(y.hi, blo, bhi) - normalize(y.lo, blo, bhi); if old_size.dist(new_size) >= 0.1 { return None; } mapping.push(i); } let membership = |loops: &[Boundary], curves: &[LCurve], pts: &[Vec2], remap: Option<&[usize]>| -> Option<(Vec>, Vec)> { let mut roles = vec![Vec::new(); pts.len()]; let mut coordinates = vec![Vec2::ZERO; pts.len()]; for (i, loop_) in loops.iter().enumerate() { let target = remap.and_then(|m| m.get(i)).copied().unwrap_or(i); for curve in &loop_.curves { for p in curve_points(curves.get(*curve)?) { if !roles.get(p)?.contains(&target) { roles.get_mut(p)?.push(target); *coordinates.get_mut(p)? += normalize(*pts.get(p)?, loop_.lo, loop_.hi); } } } } for (role, coordinate) in roles.iter_mut().zip(&mut coordinates) { if role.is_empty() { return None; } role.sort_unstable(); *coordinate = *coordinate / role.len() as f64; } Some((roles, coordinates)) }; let ((old, old_normal), (fresh, fresh_normal)) = (membership(&previous, old_curves, a, Some(&mapping))?, membership(&incoming, fresh_curves, b, None)?); let line_roles = |loops: &[Boundary], curves: &[LCurve], pts: &[Vec2]| -> Option>> { let mut roles = vec![Vec::new(); pts.len()]; for loop_ in loops { let size = loop_.hi - loop_.lo; for i in &loop_.curves { if let LCurve::Line(a, b) = *curves.get(*i)? { let d = *pts.get(b)? - *pts.get(a)?; let direction = Vec2::new(d.x / size.x.max(1e-9), d.y / size.y.max(1e-9)).normalized()?; roles.get_mut(a)?.push(direction); roles.get_mut(b)?.push(direction); } } } Some(roles) }; let old_lines = line_roles(&previous, old_curves, a)?; let fresh_lines = line_roles(&incoming, fresh_curves, b)?; Some(Roles { old, fresh, old_normal, fresh_normal, old_lines, fresh_lines }) }