feat: stabilize face attachments and closed spline workflows

This commit is contained in:
bherbruck committed 2026-10-11 05:32:46 -04:00
1 parent c1d819f235
commit a6bb8fc924
83 files changed
+6271 -385

No files matched your search

+58
View File
@@ -168,6 +168,64 @@ pub fn fit_beziers(p: &[Vec2]) -> Vec<[Vec2; 4]> {
fit_spans(p).into_iter().map(|(p0, p1, t0, t1)| [p0, p0 + t0 / 3.0, p1 - t1 / 3.0, p1]).collect()
}
/// Fit pieces in the explicitly selected interpolation mode.
pub fn fit_beziers_mode(p: &[Vec2], periodic: bool) -> crate::model::Result<Vec<[Vec2; 4]>> {
if periodic { crate::periodic_beziers(p) } else { Ok(fit_beziers(p)) }
}
pub fn spline_point_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool, t: f64) -> Vec2 {
if !periodic {
return spline_point(p, control, degree, t);
}
let Ok(pieces) = crate::periodic_beziers(p) else {
return p.first().copied().unwrap_or_default();
};
let lengths: Vec<f64> = p.windows(2).map(|p| p[0].dist(p[1])).collect();
let total = lengths.iter().sum::<f64>();
let mut remaining = t.clamp(0.0, 1.0) * total;
let mut i = 0;
while i + 1 < pieces.len() && remaining > lengths[i] {
remaining -= lengths[i];
i += 1;
}
let Some([a, b, c, d]) = pieces.get(i).copied() else {
return Vec2::ZERO;
};
let t = (remaining / lengths[i]).clamp(0.0, 1.0);
a * (1.0 - t).powi(3) + b * (3.0 * t * (1.0 - t).powi(2)) + c * (3.0 * t * t * (1.0 - t)) + d * t.powi(3)
}
pub fn spline_polyline_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool) -> Vec<Vec2> {
if !periodic {
return spline_polyline(p, control, degree);
}
let Ok(pieces) = crate::periodic_beziers(p) else {
return Vec::new();
};
let mut out = Vec::with_capacity(pieces.len() * SPAN_SAMPLES + 1);
for [a, b, c, d] in pieces {
for i in 0..SPAN_SAMPLES {
let t = i as f64 / SPAN_SAMPLES as f64;
out.push(a * (1.0 - t).powi(3) + b * (3.0 * t * (1.0 - t).powi(2)) + c * (3.0 * t * t * (1.0 - t)) + d * t.powi(3));
}
}
if let Some(first) = p.first() {
out.push(*first);
}
out
}
pub fn spline_end_tangent_mode(p: &[Vec2], control: bool, periodic: bool, at_end: bool) -> Option<Vec2> {
if !periodic {
return spline_end_tangent(p, control, at_end);
}
let pieces = crate::periodic_beziers(p).ok()?;
if at_end {
let c = pieces.last()?;
(c[3] - c[2]).normalized()
} else {
let c = pieces.first()?;
(c[1] - c[0]).normalized()
}
}
/// The Bézier pieces (each `degree + 1` points) of the clamped uniform B-spline over `ctrl`
/// (Boehm knot insertion until every interior knot has multiplicity `degree`).
pub fn bspline_beziers(ctrl: &[Vec2], degree: usize) -> Vec<Vec<Vec2>> {
+161 -18
View File
@@ -1,14 +1,19 @@
//! Persistent free-form offsets with a whole-span normal-distance error certificate.
//! The certificate bounds the second derivative of the exact normal offset and of
//! the actual natural cubic interpolant. The endpoint interpolation theorem then
//! the actual cubic interpolant (natural for legacy output, periodic for new closed output). The endpoint interpolation theorem then
//! bounds their distance everywhere, rather than just at selected samples.
use crate::{CurveKind, Sketch, SketchError, fit_beziers};
use crate::{CurveKind, Sketch, SketchError, fit_beziers_mode};
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Seg2, Vec2};
pub const OFFSET_TOLERANCE: f64 = 0.002;
const MAX_PIECES: usize = 256;
const MAX_ASSOCIATIONS: usize = 128;
const MAX_OFFSET_WORK: usize = 1_048_576;
struct Fit {
tolerance: f64,
periodic: bool,
}
type Result<T> = std::result::Result<T, SketchError>;
fn bad(message: &str) -> SketchError {
SketchError::Invalid(message.into())
@@ -285,7 +290,11 @@ fn verify_simple(points: &[Vec2], cubics: &[[Vec2; 4]], closed: bool) -> Result<
Ok(())
}
fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize, tolerance: f64) -> Result<(Vec<Vec2>, usize, bool)> {
fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize, fit: Fit, work: &mut usize) -> Result<(Vec<Vec2>, usize, bool)> {
if *work == 0 {
return Err(bad("offset transaction certificate work limit exceeded"));
}
let Fit { tolerance, periodic } = fit;
let mut pieces = sk.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?;
if pieces.is_empty()
|| pieces.len() > MAX_PIECES
@@ -333,11 +342,12 @@ fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize,
*last = first;
}
}
let cubics = fit_beziers(&points);
let mut budget = 65_536;
for (h, c) in restricted.iter().zip(&cubics) {
certify(h, c, d, tolerance, 0, &mut budget)?;
}
let cubics = fit_beziers_mode(&points, periodic && closed)?;
let allowance = (*work).min(65_536);
let mut budget = allowance;
let result = restricted.iter().zip(&cubics).try_for_each(|(h, c)| certify(h, c, d, tolerance, 0, &mut budget));
*work -= allowance - budget;
result?;
verify_simple(&points, &cubics, closed)?;
Ok((points, pieces.len(), closed))
}
@@ -377,7 +387,7 @@ impl Sketch {
|| record.output.is_empty()
|| record.output.len() > 512
|| record.source == record.output
|| record.certificate != 1
|| !matches!(record.certificate, 1 | 2)
|| record.pieces == 0
|| record.pieces > MAX_PIECES
|| record.divisions == 0
@@ -400,8 +410,10 @@ impl Sketch {
}
let output = self.curve_index(&record.output).ok_or_else(|| bad("offset output vanished"))?;
match &self.curves[output].kind {
CurveKind::Spline { pts, control: false, degree: 3 }
if pts.len() == record.pieces * record.divisions + 1
CurveKind::Spline { pts, control: false, degree: 3, periodic }
if *periodic == (record.certificate == 2)
&& (record.certificate != 2 || record.closed)
&& pts.len() == record.pieces * record.divisions + 1
&& pts.iter().all(|i| *i < self.points.len())
&& pts.iter().copied().collect::<std::collections::BTreeSet<_>>().len() == pts.len()
&& pts
@@ -420,11 +432,12 @@ impl Sketch {
let source_pieces = self.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?.len();
let mut result = None;
let mut failure = None;
let mut work = MAX_OFFSET_WORK;
for divisions in [1, 2, 4, 8, 16, 32, 64, 128, 256] {
if source_pieces.saturating_mul(divisions) >= crate::MAX_SPLINE_POINTS {
break;
}
match geometry(self, source, forward, distance, divisions, OFFSET_TOLERANCE) {
match geometry(self, source, forward, distance, divisions, Fit { tolerance: OFFSET_TOLERANCE, periodic: true }, &mut work) {
Ok(g) => {
result = Some((divisions, g));
break;
@@ -442,7 +455,7 @@ impl Sketch {
p.fixed = true;
}
}
let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3 }, None)?;
let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: closed }, None)?;
candidate.freeform_offsets.push(FreeformOffset {
source: source_id,
output: candidate.curves[output].id.clone(),
@@ -452,7 +465,7 @@ impl Sketch {
pieces,
closed,
tolerance: OFFSET_TOLERANCE,
certificate: 1,
certificate: if closed { 2 } else { 1 },
});
*self = candidate;
Ok(output)
@@ -462,8 +475,9 @@ impl Sketch {
self.validate_freeform_offsets()?;
let mut candidate = self.clone();
let mut seen = std::collections::BTreeSet::new();
let mut work = MAX_OFFSET_WORK;
for (record_index, record) in self.freeform_offsets.clone().into_iter().enumerate() {
if record.certificate != 1
if !matches!(record.certificate, 1 | 2)
|| record.source == record.output
|| !seen.insert(record.output.clone())
|| candidate.freeform_offsets.iter().any(|later| later.output == record.source && !seen.contains(&later.output))
@@ -486,7 +500,15 @@ impl Sketch {
divisions = (record.divisions / ratio).max(1);
}
let (points, pieces, closed) = loop {
match geometry(&candidate, source, record.forward, record.distance, divisions, record.tolerance) {
match geometry(
&candidate,
source,
record.forward,
record.distance,
divisions,
Fit { tolerance: record.tolerance, periodic: record.certificate == 2 },
&mut work,
) {
Ok(geometry) => break geometry,
Err(error) => {
if divisions >= 256 || source_pieces.saturating_mul(divisions.saturating_mul(2)) >= crate::MAX_SPLINE_POINTS {
@@ -500,7 +522,13 @@ impl Sketch {
return Err(bad("offset source topology changed"));
}
let old_indices = match &candidate.curves[output].kind {
CurveKind::Spline { pts, control: false, degree: 3 } if pts.len() == record.pieces * record.divisions + 1 => pts.clone(),
CurveKind::Spline { pts, control: false, degree: 3, periodic }
if *periodic == (record.certificate == 2)
&& (record.certificate != 2 || record.closed)
&& pts.len() == record.pieces * record.divisions + 1 =>
{
pts.clone()
}
_ => return Err(bad("offset output was edited; break its association first")),
};
// Refinement never reuses or removes an old node ID. Existing dyadic
@@ -526,7 +554,7 @@ impl Sketch {
p.pos = pos;
p.fixed = true;
}
candidate.curves[output].kind = CurveKind::Spline { pts: indices, control: false, degree: 3 };
candidate.curves[output].kind = CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: record.certificate == 2 };
candidate.freeform_offsets[record_index].divisions = divisions;
candidate.freeform_offsets[record_index].pieces = pieces;
}
@@ -689,9 +717,124 @@ mod tests {
fn certified_offset_closed_chain_refuses_natural_spline_seam_atomically() {
let (mut sk, source) = ellipse();
let first = sk.add_freeform_offset(source, true, -1.0).unwrap();
// Explicitly reconstruct the old saved semantics, not the new periodic output.
if let CurveKind::Spline { periodic, .. } = &mut sk.curves[first].kind {
*periodic = false;
}
sk.freeform_offsets[0].certificate = 1;
sk.refresh_offsets().unwrap();
let before = sk.clone();
let error = sk.add_freeform_offset(first, true, -0.5).unwrap_err();
assert!(error.to_string().contains("nonsmooth seam"), "{error}");
assert_eq!(sk, before);
}
#[test]
fn certified_offset_periodic_closed_chain_has_c2_seams_and_stable_roles() {
let (mut sk, source) = ellipse();
let source_points = sk.curves[source].kind.point_ids();
let unchanged = source_points.iter().map(|i| sk.points[*i].clone()).collect::<Vec<_>>();
let a = sk.add_freeform_offset(source, true, -1.0).unwrap();
let b = sk.add_freeform_offset(a, true, -0.5).unwrap();
assert_eq!(source_points.iter().map(|i| sk.points[*i].clone()).collect::<Vec<_>>(), unchanged);
let old = sk.freeform_offsets.clone();
let ids = [a, b].map(|i| sk.curves[i].kind.point_ids().iter().map(|p| sk.points[*p].id.clone()).collect::<Vec<_>>());
let verify = |sk: &Sketch| {
for output in [a, b] {
let record = sk.freeform_offsets.iter().find(|r| r.output == sk.curves[output].id).unwrap();
assert_eq!(record.certificate, 2);
let points = sk.curves[output].kind.point_ids().iter().map(|p| sk.point(*p).unwrap()).collect::<Vec<_>>();
let cubics = crate::periodic_beziers(&points).unwrap();
let first = cubics[0];
let last = *cubics.last().unwrap();
let (h0, h1) = (points[0].dist(points[1]), points[points.len() - 2].dist(points[points.len() - 1]));
assert_eq!(first[0], last[3]);
assert!(((first[1] - first[0]) / h0 - (last[3] - last[2]) / h1).len() < 1e-10);
assert!(((first[2] - first[1] * 2.0 + first[0]) / (h0 * h0) - (last[3] - last[2] * 2.0 + last[1]) / (h1 * h1)).len() < 1e-9);
let source = sk.curve_index(&record.source).unwrap();
for (i, piece) in sk.exact_segs(source).unwrap().iter().enumerate() {
for j in 0..record.divisions {
for t in [0.0, 0.13, 0.41, 0.79, 1.0] {
let exact = normal_point(piece, (j as f64 + t) / record.divisions as f64, record.distance).unwrap();
let got = sk.exact_segs(output).unwrap()[i * record.divisions + j].point_at(t);
assert!(got.dist(exact) <= OFFSET_TOLERANCE);
if source == 0 {
// Independent ellipse implicit-gradient normal, rather
// than using the offset implementation as its own oracle.
let base = piece.point_at((j as f64 + t) / record.divisions as f64);
let major = sk.point(source_points[1]).unwrap().x;
let normal = Vec2::new(base.x / (major * major), base.y / 25.0).normalized().unwrap();
assert!(got.dist(base + normal) <= OFFSET_TOLERANCE);
}
}
}
}
}
};
verify(&sk);
// Steiner's formula is independent of fitted output: both offsets expand
// the original ellipse by 1.5mm, within the accumulated two certificates.
let n = 4096;
let h = std::f64::consts::TAU / n as f64;
let perimeter = (0..=n)
.map(|i| {
let t = i as f64 * h;
(100.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt()
* if i == 0 || i == n {
1.0
} else if i % 2 == 0 {
2.0
} else {
4.0
}
})
.sum::<f64>()
* h
/ 3.0;
let expected = 50.0 * std::f64::consts::PI + 1.5 * perimeter + 2.25 * std::f64::consts::PI;
let area = sk.exact_segs(b).unwrap().iter().map(Seg2::area_term).sum::<f64>();
assert!((area - expected).abs() < 0.03, "{area} vs {expected}");
let profiles = crate::find_profiles(&sk);
assert_eq!(profiles.len(), 3);
if let CurveKind::Ellipse { m, .. } = sk.curves[source].kind {
sk.points[m].pos.x = 12.0;
}
sk.refresh_offsets().unwrap();
verify(&sk);
for (j, i) in [a, b].into_iter().enumerate() {
let now = &sk.freeform_offsets[j];
let factor = now.pieces * now.divisions / (old[j].pieces * old[j].divisions);
assert_eq!(sk.curves[i].kind.point_ids().iter().step_by(factor).map(|p| sk.points[*p].id.clone()).collect::<Vec<_>>(), ids[j]);
}
let encoded = serde_json::to_string(&sk).unwrap();
let mut reopened: Sketch = serde_json::from_str(&encoded).unwrap();
reopened.validate_periodic_splines().unwrap();
reopened.refresh_offsets().unwrap();
assert_eq!(sk, reopened);
}
#[test]
fn certified_offset_periodic_circle_and_reverse_have_analytic_normals() {
for forward in [true, false] {
let mut sk = Sketch::new();
let center = sk.add_point(Vec2::ZERO, None).unwrap();
let major = sk.add_point(Vec2::new(5.0, 0.0), None).unwrap();
let source = sk.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap();
let distance = if forward { -1.0 } else { 1.0 };
let a = sk.add_freeform_offset(source, forward, distance).unwrap();
let b = sk.add_freeform_offset(a, true, distance * 0.5).unwrap();
for (output, radius) in [(a, 6.0), (b, 6.5)] {
let pieces = sk.exact_segs(output).unwrap();
for curve in &pieces {
for t in [0.0, 0.13, 0.41, 0.79, 1.0] {
assert!((curve.point_at(t).len() - radius).abs() <= 2.0 * OFFSET_TOLERANCE);
}
}
let area = pieces.iter().map(Seg2::area_term).sum::<f64>().abs();
assert!((area - std::f64::consts::PI * radius * radius).abs() < 0.03);
}
sk.refresh_offsets().unwrap();
sk.validate_periodic_splines().unwrap();
}
}
}
+4 -1
View File
@@ -15,6 +15,7 @@ mod linalg;
mod link;
mod model;
mod offset;
mod periodic;
mod profiles;
mod shape;
mod solver;
@@ -22,13 +23,15 @@ mod text;
mod wire;
pub use curves::{
MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, spline_end_tangent, spline_point, spline_polyline,
MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, fit_beziers_mode, spline_end_tangent,
spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode,
};
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
pub use offset::OffsetChain;
pub use periodic::periodic_beziers;
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
pub use shape::{Shape, intersections};
pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
+58 -17
View File
@@ -13,6 +13,9 @@ use crate::model::{Curve, CurveKind, Result, Sketch, SketchError};
/// Points of one link closer than this are merged into one point.
const MERGE_TOL: f64 = 1e-7;
#[path = "link_loops.rs"]
mod loops;
/// Most entities a single link may produce.
pub const MAX_LINK_ENTITIES: usize = 20_000;
@@ -107,6 +110,9 @@ pub struct Link {
/// Persistent model face identity, resolved by the document before geometry refresh.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub face_name: Option<String>,
/// Automatically projected borders owned by a durable sketch face attachment.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub auto_face: bool,
/// The reference could not be found on the last recompute; the geometry is the last good one.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lost: bool,
@@ -142,6 +148,7 @@ pub enum LinkGeom {
pts: Vec<Vec2>,
control: bool,
degree: u8,
periodic: bool,
},
}
@@ -157,8 +164,11 @@ impl LinkGeom {
LinkGeom::Ellipse { c, major, minor } => {
ok(c) && ok(major) && c.dist(*major) > 1e-9 && minor.is_finite() && *minor > 1e-9 && *minor < 1e9
}
LinkGeom::Spline { pts, degree, .. } => {
(2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) && pts.iter().all(ok) && (1..=7).contains(degree)
LinkGeom::Spline { pts, control, degree, periodic } => {
(!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok()))
&& (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len())
&& pts.iter().all(ok)
&& (1..=7).contains(degree)
}
}
}
@@ -181,7 +191,7 @@ enum LCurve {
Ellipse(usize, usize, f64),
/// Start, end, first interior point and interior count (interior points are consecutive
/// and never shared), control flag, degree.
Spline(usize, usize, usize, usize, bool, u8),
Spline(usize, usize, usize, usize, bool, u8, bool),
}
fn layout(geom: &[LinkGeom]) -> Result<Layout> {
@@ -234,14 +244,14 @@ fn layout(geom: &[LinkGeom]) -> Result<Layout> {
l.curves.push(LCurve::Ellipse(c, m, minor));
}
}
LinkGeom::Spline { pts, control, degree } => {
LinkGeom::Spline { pts, control, degree, periodic } => {
let (Some(first), Some(last)) = (pts.first(), pts.last()) else { continue };
let s0 = pt(&mut l.pts, *first);
let first_in = l.pts.len();
let inner = pts.get(1..pts.len() - 1).unwrap_or_default();
l.pts.extend(inner.iter().copied());
let s1 = pt(&mut l.pts, *last);
l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree));
l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree, periodic));
}
}
}
@@ -293,7 +303,7 @@ fn same_geometry(a: &[(LCurve, Vec<Vec2>)], b: &[(LCurve, Vec<Vec2>)]) -> bool {
(LCurve::Arc(..), LCurve::Arc(..)) => all(),
(LCurve::Conic(.., ra), LCurve::Conic(.., rb)) => all() && close(*ra, *rb),
(LCurve::Ellipse(.., ra), LCurve::Ellipse(.., rb)) => all() && close(*ra, *rb),
(LCurve::Spline(.., ca, da), LCurve::Spline(.., cb, db)) => ca == cb && da == db && all(),
(LCurve::Spline(.., ca, da, pa), LCurve::Spline(.., cb, db, pb)) => ca == cb && da == db && pa == pb && all(),
_ => false,
}
};
@@ -420,7 +430,7 @@ impl Sketch {
return Err(SketchError::TooLarge);
}
let id = self.fresh("j");
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false });
self.build_link(&id, &lay)?;
Ok(id)
}
@@ -449,13 +459,13 @@ impl Sketch {
LCurve::Arc(c, a, b) => (CurveKind::Arc { c: point(self, c)?, a: point(self, a)?, b: point(self, b)? }, "a"),
LCurve::Conic(a, b, x, rho) => (CurveKind::Conic { a: point(self, a)?, b: point(self, b)?, apex: point(self, x)?, rho }, "k"),
LCurve::Ellipse(c, m, r) => (CurveKind::Ellipse { c: point(self, c)?, m: point(self, m)?, r }, "e"),
LCurve::Spline(a, b, f, n, control, degree) => {
LCurve::Spline(a, b, f, n, control, degree, periodic) => {
let mut pts = vec![point(self, a)?];
for i in f..f + n {
pts.push(point(self, i)?);
}
pts.push(point(self, b)?);
(CurveKind::Spline { pts, control, degree }, "s")
(CurveKind::Spline { pts, control, degree, periodic }, "s")
}
};
let cid = self.fresh(prefix);
@@ -484,6 +494,16 @@ impl Sketch {
return Err(SketchError::Unknown(id.into()));
}
let lay = layout(geom)?;
if self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. }))
&& lay.lone.is_empty()
&& lay.curves.len() <= 512
&& lay.pts.len() <= 512
&& lay.curves.iter().all(|c| matches!(c, LCurve::Line(..) | LCurve::Arc(..) | LCurve::Circle(..)))
&& !loops::valid(&lay.curves, &lay.pts)
{
self.set_link_lost(id, true);
return Err(SketchError::Invalid("projected face boundaries cross, touch or have ambiguous topology; last geometry retained".into()));
}
let curves = self.link_curves(id);
let points = self.link_points(id);
// Current layout of the link, in the same terms.
@@ -496,7 +516,7 @@ impl Sketch {
Some(CurveKind::Arc { c, a, b }) => Some(LCurve::Arc(local(*c)?, local(*a)?, local(*b)?)),
Some(CurveKind::Conic { a, b, apex, rho }) => Some(LCurve::Conic(local(*a)?, local(*b)?, local(*apex)?, *rho)),
Some(CurveKind::Ellipse { c, m, r }) => Some(LCurve::Ellipse(local(*c)?, local(*m)?, *r)),
Some(CurveKind::Spline { pts, control, degree }) => {
Some(CurveKind::Spline { pts, control, degree, periodic }) => {
let (a, b) = (local(*pts.first()?)?, local(*pts.last()?)?);
let inner: Vec<usize> =
pts.get(1..pts.len().saturating_sub(1)).unwrap_or_default().iter().map(|q| local(*q)).collect::<Option<_>>()?;
@@ -505,7 +525,7 @@ impl Sketch {
if inner.iter().enumerate().any(|(k, x)| *x != f + k) {
return None;
}
Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree))
Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree, *periodic))
}
_ => None,
})
@@ -561,7 +581,7 @@ impl Sketch {
LCurve::Arc(c, a, b) => LCurve::Arc(renum(c), renum(a), renum(b)),
LCurve::Conic(a, b, x, r) => LCurve::Conic(renum(a), renum(b), renum(x), r),
LCurve::Ellipse(c, m, r) => LCurve::Ellipse(renum(c), renum(m), r),
LCurve::Spline(a, b, f, n, ct, d) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d),
LCurve::Spline(a, b, f, n, ct, d, periodic) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d, periodic),
})
.collect();
let same = match &cur {
@@ -576,8 +596,8 @@ impl Sketch {
(LCurve::Arc(a0, a1, a2), LCurve::Arc(b0, b1, b2)) => a0 == b0 && a1 == b1 && a2 == b2,
(LCurve::Conic(a0, a1, a2, _), LCurve::Conic(b0, b1, b2, _)) => a0 == b0 && a1 == b1 && a2 == b2,
(LCurve::Ellipse(a0, a1, _), LCurve::Ellipse(b0, b1, _)) => a0 == b0 && a1 == b1,
(LCurve::Spline(a0, a1, a2, a3, a4, a5), LCurve::Spline(b0, b1, b2, b3, b4, b5)) => {
a0 == b0 && a1 == b1 && (a2 == b2 || *a3 == 0) && a3 == b3 && a4 == b4 && a5 == b5
(LCurve::Spline(a0, a1, a2, a3, a4, a5, a6), LCurve::Spline(b0, b1, b2, b3, b4, b5, b6)) => {
a0 == b0 && a1 == b1 && (a2 == b2 || *a3 == 0) && a3 == b3 && a4 == b4 && a5 == b5 && a6 == b6
}
_ => false,
}
@@ -625,12 +645,33 @@ impl Sketch {
let hi = ps.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect()
};
let (a, b) = (normalized(&old), normalized(&lay.pts));
let loop_roles_required = self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. }))
&& cur.iter().chain(&lay.curves).all(|c| matches!(c, LCurve::Line(..) | LCurve::Arc(..) | LCurve::Circle(..)))
&& lay.lone.is_empty();
let loop_roles = loop_roles_required.then(|| loops::roles(cur, &old, &lay.curves, &lay.pts)).flatten();
if loop_roles_required && loop_roles.is_none() {
self.set_link_lost(id, true);
return Err(SketchError::Invalid("projected face boundary identity is ambiguous; last geometry retained".into()));
}
let (a, b) = match &loop_roles {
Some(roles) => (roles.old_normal.clone(), roles.fresh_normal.clone()),
None => (normalized(&old), normalized(&lay.pts)),
};
let (old_roles, fresh_roles) = (point_roles(cur, old.len()), point_roles(&lay.curves, lay.pts.len()));
let mut mapping = Vec::new();
for (old_i, p) in a.iter().enumerate() {
let mut candidates: Vec<(usize, f64)> =
b.iter().enumerate().filter(|(i, _)| old_roles.get(old_i) == fresh_roles.get(*i)).map(|(i, q)| (i, p.dist(*q))).collect();
let mut candidates: Vec<(usize, f64)> = b
.iter()
.enumerate()
.filter(|(i, _)| {
old_roles.get(old_i) == fresh_roles.get(*i)
&& loop_roles.as_ref().is_none_or(|roles| {
roles.old.get(old_i) == roles.fresh.get(*i)
&& roles.old_lines.get(old_i).zip(roles.fresh_lines.get(*i)).is_some_and(|(a, b)| loops::same_lines(a, b))
})
})
.map(|(i, q)| (i, p.dist(*q)))
.collect();
candidates.sort_by(|x, y| x.1.total_cmp(&y.1));
if let Some((i, d)) = candidates.first()
&& *d < 0.1
+175
View File
@@ -156,3 +156,178 @@ fn resized_dense_rim_matches_arc_centres_by_role_before_proximity() {
}
assert_eq!(ids, arcs.iter().map(|i| sk.curves[*i].id.clone()).collect::<Vec<_>>());
}
#[test]
fn nested_face_resize_matches_each_loop_before_nearby_outer_vertices() {
let rectangle = |x: f64, y: f64| {
let p = [Vec2::new(-x, -y), Vec2::new(x, -y), Vec2::new(x, y), Vec2::new(-x, y)];
(0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect::<Vec<_>>()
};
let mut sk = Sketch::new();
let old: Vec<_> = rectangle(38.0, 28.0).into_iter().chain(rectangle(40.0, 30.0)).collect();
let link = sk.add_link(LinkKind::Project, face(), &old).unwrap();
let ids: Vec<_> = sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect();
let points = sk.link_points(&link);
let before: Vec<_> = points.iter().map(|i| sk.points[*i].pos).collect();
let fresh: Vec<_> = rectangle(41.0, 36.0).into_iter().chain(rectangle(38.0, 33.0)).collect();
assert!(!sk.update_link(&link, &fresh).unwrap());
assert!(!sk.link(&link).unwrap().lost);
for (i, p) in points.iter().zip(before) {
let expected = if p.x.abs() == 38.0 { Vec2::new(p.x, p.y.signum() * 33.0) } else { Vec2::new(p.x.signum() * 41.0, p.y.signum() * 36.0) };
assert_eq!(sk.points[*i].pos, expected);
}
assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::<Vec<_>>());
}
#[test]
fn growing_face_retains_stationary_hole_arcs_and_shared_centre() {
let geometry = |width: f64| {
let p = [Vec2::ZERO, Vec2::new(width, 0.0), Vec2::new(width, 30.0), Vec2::new(0.0, 30.0)];
let mut out: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect();
let c = Vec2::new(20.0, 15.0);
out.extend([
LinkGeom::Arc { c, a: c + Vec2::X * 5.0, b: c - Vec2::X * 5.0 },
LinkGeom::Arc { c, a: c - Vec2::X * 5.0, b: c + Vec2::X * 5.0 },
]);
out
};
let mut sk = Sketch::new();
let link = sk.add_link(LinkKind::Project, face(), &geometry(40.0)).unwrap();
let saved = sk.clone();
assert!(!sk.update_link(&link, &geometry(50.0)).unwrap());
assert!(!sk.link(&link).unwrap().lost);
for i in sk.link_curves(&link) {
assert_eq!(sk.curves[i].id, saved.curves[i].id);
if matches!(sk.curves[i].kind, CurveKind::Arc { .. }) {
assert_eq!(sk.curves[i], saved.curves[i]);
for p in sk.curves[i].kind.point_ids() {
assert_eq!(sk.points[p], saved.points[p]);
}
}
}
}
#[test]
fn touching_or_crossed_face_boundaries_refuse_without_retargeting_ids() {
for x in [9.0, 11.0] {
let mut sk = Sketch::new();
let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::ZERO, 1.0)]).unwrap();
let saved = sk.clone();
assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(x, 0.0), 1.0)]).is_err());
assert_eq!(sk.points, saved.points);
assert_eq!(sk.curves, saved.curves);
assert!(sk.link(&link).unwrap().lost);
}
}
#[test]
fn resized_rounded_rim_retains_inner_outer_roles_with_shared_arc_centres() {
let rounded = |x: f64, y: f64, r: f64| {
let centres = [Vec2::new(x - r, -y + r), Vec2::new(x - r, y - r), Vec2::new(-x + r, y - r), Vec2::new(-x + r, -y + r)];
let mut out = Vec::new();
for i in 0..4 {
let start = -std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::FRAC_PI_2;
let previous = centres[(i + 3) % 4] + Vec2::from_angle(start) * r;
let a = centres[i] + Vec2::from_angle(start) * r;
let b = centres[i] + Vec2::from_angle(start + std::f64::consts::FRAC_PI_2) * r;
out.push(LinkGeom::Line(previous, a));
out.push(LinkGeom::Arc { c: centres[i], a, b });
}
out
};
let mut sk = Sketch::new();
let old: Vec<_> = rounded(38.0, 28.0, 2.0).into_iter().chain(rounded(40.0, 30.0, 4.0)).collect();
let link = sk.add_link(LinkKind::Project, face(), &old).unwrap();
let curves = sk.link_curves(&link);
let ids: Vec<_> = curves.iter().map(|i| sk.curves[*i].id.clone()).collect();
let fresh: Vec<_> = rounded(41.0, 36.0, 4.0).into_iter().chain(rounded(38.0, 33.0, 1.0)).collect();
assert!(!sk.update_link(&link, &fresh).unwrap());
assert!(!sk.link(&link).unwrap().lost);
assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::<Vec<_>>());
for (old, fresh) in curves.iter().zip(rounded(38.0, 33.0, 1.0).into_iter().chain(rounded(41.0, 36.0, 4.0))) {
match (&sk.curves[*old].kind, fresh) {
(CurveKind::Line { a, b }, LinkGeom::Line(p, q)) => {
assert!(sk.points[*a].pos.dist(p) < 1e-10);
assert!(sk.points[*b].pos.dist(q) < 1e-10);
}
(CurveKind::Arc { c, a, b }, LinkGeom::Arc { c: p, a: q, b: r }) => {
assert!(sk.points[*c].pos.dist(p) < 1e-10);
assert!(sk.points[*a].pos.dist(q) < 1e-10);
assert!(sk.points[*b].pos.dist(r) < 1e-10);
}
_ => panic!(),
}
}
}
#[test]
fn far_translated_small_hole_crossing_long_edge_is_not_certified_separate() {
let mut sk = Sketch::new();
let p = [Vec2::new(-1e8, 1.0), Vec2::new(1e8, 1.0), Vec2::new(1e8, 10.0), Vec2::new(-1e8, 10.0)];
let mut old: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect();
old.push(LinkGeom::Circle(Vec2::new(1e6, 5.0), 2.0));
let link = sk.add_link(LinkKind::Project, face(), &old).unwrap();
let saved = sk.clone();
let mut fresh: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect();
fresh.push(LinkGeom::Circle(Vec2::new(1e6, 0.0), 2.0));
assert!(sk.update_link(&link, &fresh).is_err());
assert_eq!(sk.points, saved.points);
assert_eq!(sk.curves, saved.curves);
assert!(sk.link(&link).unwrap().lost);
}
#[test]
fn equal_nested_sibling_candidates_remain_ambiguous_after_loop_classification() {
let mut sk = Sketch::new();
let link = sk
.add_link(
LinkKind::Project,
face(),
&[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(-2.0, 0.0), 1.0), LinkGeom::Circle(Vec2::new(2.0, 0.0), 1.0)],
)
.unwrap();
let saved = sk.clone();
assert!(
sk.update_link(
&link,
&[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(0.0, -2.0), 1.0), LinkGeom::Circle(Vec2::new(0.0, 2.0), 1.0)]
)
.is_err()
);
assert_eq!(sk.points, saved.points);
assert_eq!(sk.curves, saved.curves);
assert!(sk.link(&link).unwrap().lost);
}
#[test]
fn tiny_circle_crossing_huge_round_boundary_is_not_certified_separate() {
let mut sk = Sketch::new();
let direction = Vec2::new(std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2);
let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 1e8), LinkGeom::Circle(direction * (1e8 + 10.0), 2.0)]).unwrap();
let saved = sk.clone();
assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 1e8), LinkGeom::Circle(direction * (1e8 + 1.0), 2.0)]).is_err());
assert_eq!(sk.points, saved.points);
assert_eq!(sk.curves, saved.curves);
assert!(sk.link(&link).unwrap().lost);
}
#[test]
fn huge_loop_does_not_make_nearby_bowtie_edges_topologically_adjacent() {
let mut sk = Sketch::new();
let link = sk.add_link(LinkKind::Project, face(), &square(10.0)).unwrap();
let saved = sk.clone();
let p = [
Vec2::ZERO,
Vec2::new(0.04, 0.04),
Vec2::new(0.0, 0.04),
Vec2::new(0.04, 0.0),
Vec2::new(9e8, 0.0),
Vec2::new(9e8, 10.0),
Vec2::new(0.0, 10.0),
];
let crossed: Vec<_> = (0..p.len()).map(|i| LinkGeom::Line(p[i], p[(i + 1) % p.len()])).collect();
assert!(sk.update_link(&link, &crossed).is_err());
assert_eq!(sk.points, saved.points);
assert_eq!(sk.curves, saved.curves);
assert!(sk.link(&link).unwrap().lost);
}
+343
View File
@@ -0,0 +1,343 @@
//! 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 solvecraft_geom::Vec2;
use std::f64::consts::TAU;
struct Boundary {
curves: Vec<usize>,
shapes: Vec<Shape>,
lo: Vec2,
hi: Vec2,
depth: usize,
signature: [usize; 3],
}
pub(super) struct Roles {
pub old: Vec<Vec<usize>>,
pub fresh: Vec<Vec<usize>>,
pub old_normal: Vec<Vec2>,
pub fresh_normal: Vec<Vec2>,
pub old_lines: Vec<Vec<Vec2>>,
pub fresh_lines: Vec<Vec<Vec2>>,
}
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<Vec2> {
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<bool> {
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<Vec<Boundary>> {
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<Roles> {
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<usize>>, Vec<Vec2>)> {
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<Vec<Vec<Vec2>>> {
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 })
}
+49 -6
View File
@@ -53,6 +53,9 @@ pub enum CurveKind {
control: bool,
#[serde(default = "three")]
degree: u8,
/// Explicit cyclic C2 fit interpolation; absent retains natural endpoints.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
periodic: bool,
},
/// Conic from `a` to `b` shaped by the `apex` point and `rho` in (0, 1) (0.5 parabola,
/// less elliptical, more hyperbolic).
@@ -111,6 +114,7 @@ impl CurveKind {
pub fn ends(&self) -> Option<(usize, usize)> {
match self {
CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } | CurveKind::Conic { a, b, .. } => Some((*a, *b)),
CurveKind::Spline { periodic: true, .. } => None,
CurveKind::Spline { pts, .. } => Some((*pts.first()?, *pts.last()?)),
CurveKind::Circle { .. } | CurveKind::Ellipse { .. } => None,
}
@@ -962,11 +966,13 @@ impl Sketch {
)
}
CurveKind::Conic { a, b, apex, rho } => Some(vec![Seg2::Conic { a: pt(a)?, apex: pt(apex)?, b: pt(b)?, w: rho / (1.0 - rho).max(1e-9) }]),
CurveKind::Spline { pts, control: false, .. } => {
CurveKind::Spline { pts, control: false, periodic, .. } => {
let p: Vec<Vec2> = pts.iter().map(pt).collect::<Option<_>>()?;
Some(crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect())
Some(
crate::curves::fit_beziers_mode(&p, *periodic).ok()?.into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect(),
)
}
CurveKind::Spline { pts, control: true, degree } if (*degree as usize) <= solvecraft_geom::MAX_BEZIER_DEGREE => {
CurveKind::Spline { pts, control: true, degree, .. } if (*degree as usize) <= solvecraft_geom::MAX_BEZIER_DEGREE => {
let p: Vec<Vec2> = pts.iter().map(pt).collect::<Option<_>>()?;
let k = (*degree as usize).min(p.len().saturating_sub(1)).max(1);
Some(crate::curves::bspline_beziers(&p, k).iter().filter_map(|c| solvecraft_geom::bezier(c)).collect())
@@ -985,9 +991,9 @@ impl Sketch {
(Some(c), Some(m)) => crate::curves::ellipse_polyline(c, m, *r),
_ => Vec::new(),
},
CurveKind::Spline { pts, control, degree } => {
CurveKind::Spline { pts, control, degree, periodic } => {
let p: Option<Vec<Vec2>> = pts.iter().map(pt).collect();
p.map(|p| crate::curves::spline_polyline(&p, *control, *degree)).unwrap_or_default()
p.map(|p| crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic)).unwrap_or_default()
}
CurveKind::Conic { a, b, apex, rho } => match (pt(a), pt(apex), pt(b)) {
(Some(a), Some(x), Some(b)) => crate::curves::conic_polyline(a, x, b, *rho),
@@ -1023,10 +1029,20 @@ impl Sketch {
}
"e"
}
CurveKind::Spline { pts, degree, .. } => {
CurveKind::Spline { pts, control, degree, periodic } => {
if pts.len() < 2 || pts.len() > crate::curves::MAX_SPLINE_POINTS || !(1..=7).contains(degree) {
return Err(SketchError::Invalid("a spline needs 2 to 500 points and degree 1 to 7".into()));
}
if *periodic {
if *control || *degree != 3 {
return Err(SketchError::Invalid("periodic mode requires a cubic fit spline".into()));
}
let p = pts
.iter()
.map(|i| self.point(*i).ok_or_else(|| SketchError::Invalid("missing periodic point".into())))
.collect::<Result<Vec<_>>>()?;
crate::periodic_beziers(&p)?;
}
"s"
}
CurveKind::Conic { rho, .. } => {
@@ -1092,7 +1108,34 @@ impl Sketch {
let gone = |p: usize| p != 0 && which.contains(&p);
let dead_curves: Vec<usize> =
self.curves.iter().enumerate().filter(|(_, c)| c.kind.point_ids().into_iter().any(gone)).map(|(i, _)| i).collect();
let private = dead_curves
.iter()
.filter_map(|i| self.curves.get(*i))
.filter(|c| self.freeform_offsets.iter().any(|r| r.output == c.id))
.flat_map(|c| c.kind.point_ids())
.collect::<std::collections::BTreeSet<_>>();
self.drop_curves(&dead_curves);
let mut removed = which.iter().copied().filter(|i| *i != 0).collect::<std::collections::BTreeSet<_>>();
// Keep any node still named by a surviving curve or constraint.
let referenced = self.curves.iter().flat_map(|c| c.kind.point_ids()).collect::<std::collections::BTreeSet<_>>();
let constraint_points = std::cell::RefCell::new(std::collections::BTreeSet::new());
for constraint in &self.constraints {
let mut kind = constraint.kind.clone();
kind.remap(
&|p| {
constraint_points.borrow_mut().insert(p);
Some(p)
},
&Some,
);
}
let constraint_points = constraint_points.into_inner();
for index in private {
if index != 0 && !referenced.contains(&index) && !constraint_points.contains(&index) {
removed.insert(index);
}
}
let gone = |p: usize| removed.contains(&p);
let mut pmap = vec![None; self.points.len()];
let mut n = 0;
for (i, slot) in pmap.iter_mut().enumerate() {
+204
View File
@@ -0,0 +1,204 @@
//! Explicit cyclic C2 fit splines. Missing periodic markers retain natural interpolation.
use crate::{MAX_SPLINE_POINTS, Sketch, SketchError};
use solvecraft_geom::Vec2;
type Result<T> = std::result::Result<T, SketchError>;
fn bad() -> SketchError {
SketchError::Invalid("periodic fit spline needs 3 distinct cyclic samples, an exact closing point, and regular bounded chords".into())
}
pub(crate) fn validate_periodic_points(points: &[Vec2]) -> Result<()> {
if points.len() < 4
|| points.len() > MAX_SPLINE_POINTS
|| points.first() != points.last()
|| points.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9)
|| points.windows(2).any(|p| {
let h = p[0].dist(p[1]);
!h.is_finite() || h <= 1e-9
})
{
return Err(bad());
}
let mut distinct = Vec::new();
for p in points {
if !distinct.contains(p) {
distinct.push(*p);
}
if distinct.len() == 3 {
return Ok(());
}
}
Err(bad())
}
pub fn periodic_beziers(points: &[Vec2]) -> Result<Vec<[Vec2; 4]>> {
validate_periodic_points(points)?;
let n = points.len() - 1;
let h: Vec<f64> = points.windows(2).map(|p| p[0].dist(p[1])).collect();
let mut diagonal = Vec::with_capacity(n);
let mut rhs = Vec::with_capacity(n);
for i in 0..n {
let previous = (i + n - 1) % n;
diagonal.push(2.0 * (h[previous] + h[i]));
rhs.push(((points[i + 1] - points[i]) / h[i] - (points[i] - points[previous]) / h[previous]) * 6.0);
}
// Remove the two corner entries with a rank-one correction. The underlying
// cyclic matrix is strictly diagonally dominant; all pivots are still checked.
let corner = h[n - 1];
let gamma = -diagonal[0];
diagonal[0] -= gamma;
diagonal[n - 1] -= corner * corner / gamma;
let solve = |mut rhs: Vec<Vec2>| -> Result<Vec<Vec2>> {
let mut diagonal = diagonal.clone();
for i in 1..n {
if !diagonal[i - 1].is_finite() || diagonal[i - 1].abs() < 1e-12 * (h[i - 1] + h[(i + n - 2) % n]) {
return Err(bad());
}
let factor = h[i - 1] / diagonal[i - 1];
diagonal[i] -= factor * h[i - 1];
rhs[i] = rhs[i] - rhs[i - 1] * factor;
}
for i in (0..n).rev() {
if !diagonal[i].is_finite() || diagonal[i].abs() < 1e-12 * (h[i] + h[(i + n - 1) % n]) {
return Err(bad());
}
let next = rhs.get(i + 1).copied().unwrap_or_default() * h[i];
rhs[i] = (rhs[i] - next) / diagonal[i];
if !rhs[i].is_finite() {
return Err(bad());
}
}
Ok(rhs)
};
let mut second = solve(rhs)?;
let mut correction_rhs = vec![Vec2::ZERO; n];
correction_rhs[0].x = gamma;
correction_rhs[n - 1].x = corner;
let correction = solve(correction_rhs)?;
let denominator = 1.0 + correction[0].x + corner * correction[n - 1].x / gamma;
if !denominator.is_finite() || denominator.abs() <= 1e-12 {
return Err(bad());
}
let factor = (second[0] + second[n - 1] * (corner / gamma)) / denominator;
for (value, correction) in second.iter_mut().zip(&correction) {
*value -= factor * correction.x;
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let next = (i + 1) % n;
let chord = points[i + 1] - points[i];
let t0 = chord - (second[i] * 2.0 + second[next]) * (h[i] * h[i] / 6.0);
let t1 = chord + (second[i] + second[next] * 2.0) * (h[i] * h[i] / 6.0);
let cubic = [points[i], points[i] + t0 / 3.0, points[i + 1] - t1 / 3.0, points[i + 1]];
if cubic.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) {
return Err(bad());
}
out.push(cubic);
}
Ok(out)
}
impl Sketch {
pub fn validate_periodic_splines(&self) -> Result<()> {
let mut work = 0usize;
for curve in &self.curves {
if let crate::CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind {
work = work.saturating_add(pts.len());
if *control || *degree != 3 || work > 1_000_000 || pts.len() > MAX_SPLINE_POINTS {
return Err(bad());
}
let points = pts.iter().map(|i| self.point(*i).ok_or_else(bad)).collect::<Result<Vec<_>>>()?;
validate_periodic_points(&points)?;
}
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn periodic_c2_unequal_chords_and_legacy_natural_are_distinct() {
let p = [Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(3.0, 2.0), Vec2::new(-1.0, 1.0), Vec2::new(0.0, 0.0)];
let curves = periodic_beziers(&p).unwrap();
for i in 0..curves.len() {
let j = (i + 1) % curves.len();
let (a, b) = (curves[i], curves[j]);
let (ha, hb) = (p[i].dist(p[i + 1]), p[j].dist(p[j + 1]));
assert_eq!(a[3], b[0]);
assert!(((a[3] - a[2]) * (3.0 / ha) - (b[1] - b[0]) * (3.0 / hb)).len() < 1e-12);
assert!(((a[3] - a[2] * 2.0 + a[1]) * (6.0 / (ha * ha)) - (b[2] - b[1] * 2.0 + b[0]) * (6.0 / (hb * hb))).len() < 1e-12);
}
let legacy = crate::fit_beziers(&p);
assert!(
(legacy[0][1] - legacy[0][0]).normalized().unwrap().dist((legacy.last().unwrap()[3] - legacy.last().unwrap()[2]).normalized().unwrap())
> 0.01
);
let global = |t| crate::spline_point_mode(&p, false, 3, true, t);
let h = 1e-5;
assert!(((global(h) - global(0.0)) / h - (global(1.0) - global(1.0 - h)) / h).len() < 0.002);
}
#[test]
fn periodic_rejects_two_samples_and_hostile_geometry() {
let a = Vec2::ZERO;
let b = Vec2::X;
assert!(periodic_beziers(&[a, b, a, b, a]).is_err());
assert!(periodic_beziers(&[a, b, Vec2::new(0.0, 1.0), b]).is_err());
assert!(periodic_beziers(&[a, b, Vec2::new(f64::INFINITY, 1.0), a]).is_err());
assert!(periodic_beziers(&vec![a; MAX_SPLINE_POINTS + 1]).is_err());
}
#[test]
fn periodic_solver_closes_shared_roles_and_refuses_immutable_or_collapsed_nodes() {
let mut sk = Sketch::new();
let first = sk.add_point(Vec2::ZERO, None).unwrap();
let a = sk.add_point(Vec2::new(4.0, 0.0), None).unwrap();
let b = sk.add_point(Vec2::new(3.0, 2.0), None).unwrap();
let seam = sk.add_point(Vec2::ZERO, None).unwrap();
let c = sk.add_point(Vec2::new(-4.0, 0.0), None).unwrap();
let d = sk.add_point(Vec2::new(-3.0, -2.0), None).unwrap();
let last = sk.add_point(Vec2::ZERO, None).unwrap();
sk.add_curve(crate::CurveKind::Spline { pts: vec![first, a, b, seam], control: false, degree: 3, periodic: true }, None).unwrap();
sk.add_curve(crate::CurveKind::Spline { pts: vec![seam, c, d, last], control: false, degree: 3, periodic: true }, None).unwrap();
sk.points[first].pos = Vec2::new(0.2, 0.1);
assert!(crate::solve_holding(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }).ok());
assert_eq!(sk.point(first), sk.point(seam));
assert_eq!(sk.point(first), sk.point(last));
sk.validate_periodic_splines().unwrap();
let mut fixed = sk.clone();
fixed.points[first].fixed = true;
fixed.points[seam].fixed = true;
fixed.points[seam].pos.x += 1e-8;
let before = fixed.clone();
assert!(!crate::solve(&mut fixed).ok());
assert_eq!(fixed, before);
let mut collapsed = sk;
collapsed.points[a].pos = collapsed.points[first].pos;
let before = collapsed.clone();
assert!(!crate::solve_holding(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }).ok());
assert_eq!(collapsed, before);
}
#[test]
fn periodic_link_projection_keeps_mode_and_break_geometry() {
let p = vec![Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(3.0, 2.0), Vec2::new(-1.0, 1.0), Vec2::ZERO];
let mut sk = Sketch::new();
let source = crate::LinkSource::SketchCurve { sketch: 99, curve: "source".into() };
let geometry = crate::LinkGeom::Spline { pts: p.clone(), control: false, degree: 3, periodic: true };
let id = sk.add_link(crate::LinkKind::Project, source, &[geometry]).unwrap();
let curve = sk.link_curves(&id)[0];
assert!(matches!(sk.curves[curve].kind, crate::CurveKind::Spline { periodic: true, .. }));
let before = sk.exact_segs(curve).unwrap();
sk.break_link(&id).unwrap();
assert_eq!(sk.exact_segs(curve).unwrap(), before);
sk.validate_periodic_splines().unwrap();
let mut singular = Sketch::new();
let points =
[Vec2::ZERO, Vec2::X, Vec2::ZERO, Vec2::new(0.0, 1.0), Vec2::ZERO].into_iter().map(|p| singular.add_point(p, None).unwrap()).collect();
let curve = singular.add_curve(crate::CurveKind::Spline { pts: points, control: false, degree: 3, periodic: true }, None).unwrap();
let before = singular.clone();
assert!(singular.add_freeform_offset(curve, true, 0.1).is_err());
assert_eq!(singular, before);
}
}
+108 -12
View File
@@ -169,9 +169,9 @@ impl State<'_> {
};
crate::curves::ellipse_polyline(self.p(*ci), self.p(*m), r)
}
Some(CurveKind::Spline { pts, control, degree }) => {
let p: Vec<Vec2> = pts.iter().map(|q| self.p(*q)).collect();
crate::curves::spline_polyline(&p, *control, *degree)
Some(CurveKind::Spline { pts, control, degree, periodic }) => {
let p = self.spline_points(pts, *periodic);
crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic)
}
Some(CurveKind::Conic { a, b, apex, rho }) => crate::curves::conic_polyline(self.p(*a), self.p(*apex), self.p(*b), *rho),
_ => Vec::new(),
@@ -179,6 +179,16 @@ impl State<'_> {
}
/// Point-on-curve constraints use the exact shape. A polyline residual would move an
/// exact crossing onto its chord, changing geometry merely by splitting it.
fn spline_points(&self, pts: &[usize], periodic: bool) -> Vec<Vec2> {
let mut points: Vec<Vec2> = pts.iter().map(|i| self.p(*i)).collect();
if periodic
&& let Some(first) = points.first().copied()
&& let Some(last) = points.last_mut()
{
*last = first;
}
points
}
fn freeform_dist(&self, c: usize, q: Vec2) -> f64 {
let segs: Vec<Seg2> = match self.sk.curves.get(c).map(|c| &c.kind) {
Some(CurveKind::Ellipse { c: ci, m, .. }) => {
@@ -194,8 +204,8 @@ impl State<'_> {
let y = delta.cross(u) / (a * r);
return (x.hypot(y) - 1.0) * a.min(r);
}
Some(CurveKind::Spline { pts, control, degree }) => {
let p: Vec<Vec2> = pts.iter().map(|i| self.p(*i)).collect();
Some(CurveKind::Spline { pts, control, degree, periodic }) => {
let p = self.spline_points(pts, *periodic);
if p.len() < 2 {
return q.dist(p.first().copied().unwrap_or_default());
}
@@ -203,7 +213,11 @@ impl State<'_> {
let k = (*degree as usize).min(solvecraft_geom::MAX_BEZIER_DEGREE).min(p.len().saturating_sub(1)).max(1);
crate::curves::bspline_beziers(&p, k).iter().filter_map(|span| solvecraft_geom::bezier(span)).collect()
} else {
crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect()
crate::curves::fit_beziers_mode(&p, *periodic)
.unwrap_or_default()
.into_iter()
.map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 })
.collect()
}
}
Some(CurveKind::Conic { a, b, apex, rho }) => {
@@ -269,10 +283,10 @@ impl State<'_> {
let (cc, r) = self.round(c);
(cc - self.p(p)) / (r * r).max(1e-24)
}
CurveKind::Spline { pts, control, degree } => {
let q: Vec<Vec2> = pts.iter().map(|i| self.p(*i)).collect();
CurveKind::Spline { pts, control, degree, periodic } => {
let q = self.spline_points(pts, *periodic);
let at_end = pts.last() == Some(&p) && pts.first() != Some(&p);
crate::curves::end_curvature(&|t| crate::curves::spline_point(&q, *control, *degree, t), at_end)
crate::curves::end_curvature(&|t| crate::curves::spline_point_mode(&q, *control, *degree, *periodic, t), at_end)
}
CurveKind::Conic { a, b, apex, rho } => {
let (pa, pb, px) = (self.p(*a), self.p(*b), self.p(*apex));
@@ -285,9 +299,9 @@ impl State<'_> {
match self.sk.curves.get(c).map(|c| &c.kind)? {
CurveKind::Line { a, b } => (self.p(*b) - self.p(*a)).normalized(),
CurveKind::Circle { .. } | CurveKind::Arc { .. } => (self.p(p) - self.round(c).0).perp().normalized(),
CurveKind::Spline { pts, control, .. } => {
let q: Vec<Vec2> = pts.iter().map(|i| self.p(*i)).collect();
crate::curves::spline_end_tangent(&q, *control, pts.last() == Some(&p) && pts.first() != Some(&p))
CurveKind::Spline { pts, control, periodic, .. } => {
let q = self.spline_points(pts, *periodic);
crate::curves::spline_end_tangent_mode(&q, *control, *periodic, pts.last() == Some(&p) && pts.first() != Some(&p))
}
CurveKind::Conic { a, b, apex, .. } => {
if p == *a {
@@ -540,6 +554,7 @@ pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option<f64> {
/// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known
/// touch point (better conditioned than the distance form when an end point is on the curve).
enum Block<'a> {
Closure { a: usize, b: usize },
Arc { c: usize, a: usize, b: usize },
User(usize, &'a ConstraintKind),
TangentAt(usize, &'a ConstraintKind, usize),
@@ -547,6 +562,7 @@ enum Block<'a> {
fn copy_block<'a>(b: &Block<'a>) -> Block<'a> {
match b {
Block::Closure { a, b } => Block::Closure { a: *a, b: *b },
Block::Arc { c, a, b } => Block::Arc { c: *c, a: *a, b: *b },
Block::User(i, k) => Block::User(*i, k),
Block::TangentAt(i, k, p) => Block::TangentAt(*i, k, *p),
@@ -555,6 +571,10 @@ fn copy_block<'a>(b: &Block<'a>) -> Block<'a> {
fn block_residuals(s: &State, b: &Block, out: &mut Vec<f64>) {
match b {
Block::Closure { a, b } => {
let d = s.p(*a) - s.p(*b);
out.extend([d.x, d.y]);
}
Block::Arc { c, a, b } => {
let cc = s.p(*c);
out.push(cc.dist(s.p(*b)) - cc.dist(s.p(*a)));
@@ -625,6 +645,10 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec<usize> {
};
use ConstraintKind::*;
match b {
Block::Closure { a, b } => {
pt(&mut v, *a);
pt(&mut v, *b);
}
Block::Arc { c, a, b } => {
pt(&mut v, *c);
pt(&mut v, *a);
@@ -947,6 +971,14 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport {
let mut blocks: Vec<(Block, Vec<usize>)> = Vec::new();
for c in &sk.curves {
if let CurveKind::Spline { pts, periodic: true, .. } = &c.kind
&& let (Some(a), Some(b)) = (pts.first(), pts.last())
&& a != b
{
let bl = Block::Closure { a: *a, b: *b };
let vars = block_vars(sk, &lay, &bl);
blocks.push((bl, vars));
}
if let CurveKind::Arc { c, a, b } = c.kind {
let bl = Block::Arc { c, a, b };
let vars = block_vars(sk, &lay, &bl);
@@ -1067,6 +1099,63 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport {
let (r, _, owner) = system(sk, &lay, &consts, &mut x, false);
check(&r, &owner, &consts, &mut failing);
}
// Weld the whole connected closure graph, preserving every immutable node.
// Validate the actual prospective geometry before accepting a numerical solve.
let has_periodic = sk.curves.iter().any(|c| matches!(c.kind, CurveKind::Spline { periodic: true, .. }));
let periodic_positions = if has_periodic {
let state = State { sk, lay: &lay, x: &x };
let mut positions = (0..sk.points.len()).map(|i| state.p(i)).collect::<Vec<_>>();
let mut parent = (0..positions.len()).collect::<Vec<_>>();
for curve in &sk.curves {
if let CurveKind::Spline { pts, periodic: true, .. } = &curve.kind
&& let (Some(a), Some(b)) = (pts.first(), pts.last())
&& *a < parent.len()
&& *b < parent.len()
{
let (a, b) = (find(&mut parent, *a), find(&mut parent, *b));
if let Some(slot) = parent.get_mut(b) {
*slot = a;
}
}
}
let mut representatives = std::collections::BTreeMap::new();
let mut valid = true;
for i in 0..positions.len() {
let root = find(&mut parent, i);
if let Some(previous) = representatives.get(&root).copied() {
let fixed = |p| lay.pvar.get(p).is_some_and(Option::is_none);
if fixed(i) && fixed(previous) && positions[i] != positions[previous] {
valid = false;
}
if fixed(i) && !fixed(previous) {
representatives.insert(root, i);
}
} else {
representatives.insert(root, i);
}
}
for (i, position) in positions.iter_mut().enumerate() {
if let Some(index) = representatives.get(&find(&mut parent, i)) {
*position = state.p(*index);
}
}
for curve in &sk.curves {
if let CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind {
let points = pts.iter().map(|i| positions.get(*i).copied()).collect::<Option<Vec<_>>>();
if *control || *degree != 3 || points.as_ref().is_none_or(|p| crate::periodic::validate_periodic_points(p).is_err()) {
valid = false;
}
}
}
if !valid {
max_residual = max_residual.max(1.0);
None
} else {
Some(positions)
}
} else {
None
};
let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed };
let use_x = if status == SolveStatus::Solved { &x } else { &x0 };
// Write back.
@@ -1080,6 +1169,13 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport {
*r = use_x.get(v).copied().unwrap_or(*r).abs().max(1e-9);
}
}
if status == SolveStatus::Solved
&& let Some(positions) = periodic_positions
{
for (point, position) in sk.points.iter_mut().zip(positions) {
point.pos = position;
}
}
if status == SolveStatus::Failed {
for (ci, a_before, b_before, reversed) in orientation_before.into_iter().rev() {
if let Some(curve) = sk.curves.get_mut(ci)
+2 -2
View File
@@ -424,7 +424,7 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() {
.iter()
.map(|p| sk.add_point(*p, None).unwrap())
.collect();
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5 }, None).unwrap();
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5, periodic: false }, None).unwrap();
sk.add_line_pts(ids[5], ids[0], None).unwrap();
let pr = find_profiles(&sk);
assert_eq!(pr.len(), 1);
@@ -433,7 +433,7 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() {
// A fit spline arch over a base line, cut by a vertical line at x = 10.
let mut sk = Sketch::default();
let ids: Vec<usize> = [v(0.0, 0.0), v(6.0, 9.0), v(14.0, 9.0), v(20.0, 0.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3 }, None).unwrap();
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3, periodic: false }, None).unwrap();
sk.add_line_pts(ids[3], ids[0], None).unwrap();
let (a, b) = (sk.add_point(v(10.0, -5.0), None).unwrap(), sk.add_point(v(10.0, 20.0), None).unwrap());
sk.add_line_pts(a, b, None).unwrap();
+1 -1
View File
@@ -196,7 +196,7 @@ impl Sketch {
}
let id = if geom.is_empty() {
let id = self.fresh("j");
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false });
id
} else {
self.add_link(kind, source, geom)?