feat: strengthen modeling, configuration, and assembly workflows

Preserve linked sketch identities through deterministic face projection and resizing. Fix origin-corner picking, persistent inline navigation, appearance editing, configuration styles, component properties, contact sweeps, sheet exports, analytic STEP offsets, and bounded rolling-ball fit accuracy.

Validated with full cargo xtask ci, native workflow screenshots, repeated enclosure replays, and analytic geometry regressions.
This commit is contained in:
bherbruck committed 2026-10-11 00:37:26 -04:00
1 parent 22672585c9
commit d71bdb78ec
54 files changed
+4179 -316

No files matched your search

+141 -31
View File
@@ -300,6 +300,13 @@ fn same_geometry(a: &[(LCurve, Vec<Vec2>)], b: &[(LCurve, Vec<Vec2>)]) -> bool {
if a.len() != b.len() {
return false;
}
// Unchanged ordered links are common; avoid a quadratic permutation scan.
if a.iter().zip(b).all(|(x, y)| matches(x, y)) {
return true;
}
if a.len() > 512 {
return false;
}
let mut used = vec![false; b.len()];
a.iter().all(|x| {
let hit = b.iter().enumerate().find(|(j, y)| !used.get(*j).copied().unwrap_or(true) && matches(x, y)).map(|(j, _)| j);
@@ -330,6 +337,48 @@ fn curve_points(c: &LCurve) -> Vec<usize> {
}
}
/// Incidence roles distinguish nearby arc centres, endpoints and hole centres during a
/// resize. Spatial proximity alone can select a different kind of point in a dense rim.
fn point_roles(curves: &[LCurve], point_count: usize) -> Vec<[usize; 10]> {
let mut roles = vec![[0; 10]; point_count];
let mut add = |point: usize, role: usize| {
if let Some(count) = roles.get_mut(point).and_then(|r| r.get_mut(role)) {
*count += 1;
}
};
for curve in curves {
match *curve {
LCurve::Line(a, b) => {
add(a, 0);
add(b, 0);
}
LCurve::Circle(c, _) => add(c, 1),
LCurve::Arc(c, a, b) => {
add(c, 2);
add(a, 3);
add(b, 3);
}
LCurve::Conic(a, b, x, _) => {
add(a, 4);
add(b, 4);
add(x, 5);
}
LCurve::Ellipse(c, m, _) => {
add(c, 6);
add(m, 7);
}
LCurve::Spline(a, b, first, count, ..) => {
add(a, 8);
add(b, 8);
for i in first..first + count {
add(i, 9);
}
}
}
}
roles
}
impl Sketch {
pub fn link(&self, id: &str) -> Option<&Link> {
self.links.iter().find(|l| l.id == id)
@@ -539,26 +588,6 @@ impl Sketch {
if let Some(l) = self.links.iter_mut().find(|l| l.id == id) {
l.lost = false;
}
if same {
for (k, pi) in points.iter().enumerate() {
let p = order.get(k).and_then(|li| lay.pts.get(*li)).copied();
if let (Some(p), Some(sp)) = (p, self.points.get_mut(*pi)) {
sp.pos = p;
}
}
for (ci, c) in curves.iter().zip(&new) {
if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*slot = *r;
}
if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*rho = *r;
}
if let (LCurve::Ellipse(.., r), Some(Curve { kind: CurveKind::Ellipse { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*slot = *r;
}
}
return Ok(false);
}
// The same geometry walked in another order or direction (e.g. a face loop re-read after
// a rollback) is not a change: keep the entities, and the constraints on them.
if let Some(cur) = &cur {
@@ -588,6 +617,7 @@ impl Sketch {
&& points.len() == lay.pts.len()
&& cur.len() == lay.curves.len()
&& points.len() <= 512
&& cur.len() <= 512
{
let old: Vec<Vec2> = points.iter().filter_map(|i| self.point(*i)).collect();
let normalized = |ps: &[Vec2]| -> Vec<Vec2> {
@@ -596,9 +626,11 @@ impl Sketch {
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 (old_roles, fresh_roles) = (point_roles(cur, old.len()), point_roles(&lay.curves, lay.pts.len()));
let mut mapping = Vec::new();
for p in &a {
let mut candidates: Vec<(usize, f64)> = b.iter().enumerate().map(|(i, q)| (i, p.dist(*q))).collect();
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();
candidates.sort_by(|x, y| x.1.total_cmp(&y.1));
if let Some((i, d)) = candidates.first()
&& *d < 0.1
@@ -613,22 +645,59 @@ impl Sketch {
let mut used = Vec::new();
let mut curve_map = Vec::new();
if mapping.len() == points.len() {
for c in cur {
for (curve_i, c) in cur.iter().enumerate() {
let mapped: Option<Vec<usize>> = curve_points(c).iter().map(|i| mapping.get(*i).copied()).collect();
let found = lay
let candidates: Vec<usize> = lay
.curves
.iter()
.enumerate()
.find(|(i, fresh)| {
if used.contains(i) || !same_shape(c, fresh) {
return false;
.filter_map(|(i, fresh)| {
if !same_shape(c, fresh) {
return None;
}
let fresh_pts = curve_points(fresh);
mapped.as_ref().is_some_and(|p| {
*p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::<Vec<_>>() == fresh_pts
})
mapped
.as_ref()
.is_some_and(|p| {
*p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::<Vec<_>>() == fresh_pts
})
.then_some(i)
})
.map(|(i, _)| i);
.collect();
let found = if candidates.len() == 1 {
candidates.first().copied()
} else if let LCurve::Circle(center, _) = c {
// Concentric circular boundaries share a point graph. Their radius
// order identifies the inner/outer rings through an upstream resize.
let mut previous: Vec<(usize, f64)> = cur
.iter()
.enumerate()
.filter_map(|(i, x)| match x {
LCurve::Circle(p, r) if p == center => Some((i, *r)),
_ => None,
})
.collect();
let mut incoming: Vec<(usize, f64)> = candidates
.iter()
.filter_map(|i| match lay.curves.get(*i) {
Some(LCurve::Circle(_, r)) => Some((*i, *r)),
_ => None,
})
.collect();
previous.sort_by(|a, b| a.1.total_cmp(&b.1));
incoming.sort_by(|a, b| a.1.total_cmp(&b.1));
let distinct = |xs: &[(usize, f64)]| {
xs.windows(2).all(|w| matches!(w, [a, b] if b.1 - a.1 > MERGE_TOL * (1.0 + a.1.abs().max(b.1.abs()))))
};
if previous.len() == incoming.len() && distinct(&previous) && distinct(&incoming) {
previous.iter().position(|(i, _)| *i == curve_i).and_then(|rank| incoming.get(rank).map(|(i, _)| *i))
} else {
None
}
} else {
None
};
let found = found.filter(|i| !used.contains(i));
if let Some(i) = found {
used.push(i);
curve_map.push(i);
@@ -649,10 +718,47 @@ impl Sketch {
{
*r = *value;
}
if let (Some(Curve { kind: CurveKind::Conic { rho, .. }, .. }), Some(LCurve::Conic(.., value))) =
(self.curves.get_mut(*ci), lay.curves.get(li))
{
*rho = *value;
}
if let (Some(Curve { kind: CurveKind::Ellipse { r, .. }, .. }), Some(LCurve::Ellipse(.., value))) =
(self.curves.get_mut(*ci), lay.curves.get(li))
{
*r = *value;
}
}
return Ok(false);
}
}
// Equal topology alone does not identify a face boundary: an outer rim and a
// hole can have identical point graphs. Never move saved curve ids between them
// when the geometric match is ambiguous; the document reports a lost link.
if same && self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. })) {
self.set_link_lost(id, true);
return Err(SketchError::Invalid("projected face boundary identity is ambiguous; last geometry retained".into()));
}
if same {
for (k, pi) in points.iter().enumerate() {
let p = order.get(k).and_then(|li| lay.pts.get(*li)).copied();
if let (Some(p), Some(sp)) = (p, self.points.get_mut(*pi)) {
sp.pos = p;
}
}
for (ci, c) in curves.iter().zip(&new) {
if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*slot = *r;
}
if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*rho = *r;
}
if let (LCurve::Ellipse(.., r), Some(Curve { kind: CurveKind::Ellipse { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*slot = *r;
}
}
return Ok(false);
}
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
self.drop_link_entities(id);
self.build_link(id, &lay)?;
@@ -724,3 +830,7 @@ impl Sketch {
self.links.retain(|_| k.next().copied().unwrap_or(true));
}
}
#[cfg(test)]
#[path = "link_identity_tests.rs"]
mod identity_tests;
+158
View File
@@ -0,0 +1,158 @@
use super::*;
use crate::{ConstraintKind, solve};
fn square(size: f64) -> Vec<LinkGeom> {
let pts = [Vec2::new(-size, -size), Vec2::new(size, -size), Vec2::new(size, size), Vec2::new(-size, size)];
(0..4).map(|i| LinkGeom::Line(pts[i], pts[(i + 1) % 4])).collect()
}
fn face() -> LinkSource {
LinkSource::Face { body: "Case".into(), at: Vec3::ZERO }
}
fn ring(outer_first: bool, outer: f64, inner: f64) -> Vec<LinkGeom> {
let (first, second) = if outer_first { (outer, inner) } else { (inner, outer) };
square(first).into_iter().chain(square(second)).collect()
}
#[test]
fn saved_face_link_keeps_inner_ids_when_boundary_order_changes() {
let mut sk = Sketch::new();
// An older saved face may have enumerated its inner boundary first.
let link = sk.add_link(LinkKind::Project, face(), &ring(false, 10.0, 8.0)).unwrap();
let inner = sk.link_curves(&link)[0];
let corner = match sk.curves[inner].kind {
CurveKind::Line { a, .. } => a,
_ => panic!(),
};
let free = sk.add_point(Vec2::new(-8.0, -8.0), None).unwrap();
sk.add_constraint(ConstraintKind::Coincident { p: free, q: corner }, None).unwrap();
let serialized = serde_json::to_string(&sk).unwrap();
let mut sk: Sketch = serde_json::from_str(&serialized).unwrap();
let before = sk.clone();
assert!(!sk.update_link(&link, &ring(true, 10.0, 8.0)).unwrap());
assert_eq!(sk, before, "canonical outer-first enumeration must not reinterpret saved ids");
assert!(solve(&mut sk).ok());
assert_eq!(sk.points[free].pos, Vec2::new(-8.0, -8.0));
}
#[test]
fn resized_and_reordered_face_keeps_constraints_on_the_same_boundary() {
let mut sk = Sketch::new();
let link = sk.add_link(LinkKind::Project, face(), &ring(false, 10.0, 8.0)).unwrap();
let points = sk.link_points(&link);
let original: Vec<Vec2> = points.iter().map(|p| sk.points[*p].pos).collect();
let ids: Vec<String> = sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect();
let free = sk.add_point(Vec2::new(-8.0, -8.0), None).unwrap();
sk.add_constraint(ConstraintKind::Coincident { p: free, q: points[0] }, None).unwrap();
let fresh: Vec<LinkGeom> = ring(true, 10.0, 8.0)
.into_iter()
.map(|g| match g {
LinkGeom::Line(a, b) => LinkGeom::Line(Vec2::new(b.x * 2.0, b.y * 3.0), Vec2::new(a.x * 2.0, a.y * 3.0)),
_ => panic!(),
})
.collect();
assert!(!sk.update_link(&link, &fresh).unwrap());
for (p, old) in points.iter().zip(original) {
assert_eq!(sk.points[*p].pos, Vec2::new(old.x * 2.0, old.y * 3.0));
}
assert_eq!(ids, sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect::<Vec<_>>());
assert!(solve(&mut sk).ok());
assert!(sk.points[free].pos.dist(Vec2::new(-16.0, -24.0)) < 1e-7);
}
#[test]
fn ambiguous_face_refresh_preserves_last_geometry_and_marks_link_lost() {
let mut sk = Sketch::new();
let link = sk.add_link(LinkKind::Project, face(), &ring(true, 10.0, 9.9)).unwrap();
let before = sk.clone();
assert!(sk.update_link(&link, &ring(false, 10.0, 2.0)).is_err());
assert_eq!(sk.points, before.points);
assert_eq!(sk.curves, before.curves);
assert!(sk.link(&link).unwrap().lost);
}
#[test]
fn large_face_permutation_is_bounded_and_preserves_geometry() {
let mut sk = Sketch::new();
let geom: Vec<LinkGeom> = (1..=129).flat_map(|i| square(i as f64)).collect();
let link = sk.add_link(LinkKind::Project, face(), &geom).unwrap();
assert!(!sk.update_link(&link, &geom).unwrap(), "ordered unchanged geometry needs no permutation scan");
let before = sk.clone();
let reversed: Vec<LinkGeom> = (1..=129).rev().flat_map(|i| square(i as f64)).collect();
assert!(sk.update_link(&link, &reversed).is_err());
assert_eq!(sk.points, before.points);
assert_eq!(sk.curves, before.curves);
}
#[test]
fn geometric_refresh_updates_conic_and_ellipse_scalar_values() {
let mut sk = Sketch::new();
let geom = |rho, minor| {
vec![
LinkGeom::Conic { a: Vec2::new(1.0, 1.0), apex: Vec2::new(2.0, 3.0), b: Vec2::new(4.0, 1.0), rho },
LinkGeom::Ellipse { c: Vec2::new(10.0, 10.0), major: Vec2::new(14.0, 10.0), minor },
]
};
let link = sk.add_link(LinkKind::Project, LinkSource::Origin, &geom(0.4, 2.0)).unwrap();
assert!(!sk.update_link(&link, &geom(0.6, 3.0)).unwrap());
assert!(matches!(sk.curves[0].kind, CurveKind::Conic { rho, .. } if rho == 0.6));
assert!(matches!(sk.curves[1].kind, CurveKind::Ellipse { r, .. } if r == 3.0));
}
#[test]
fn many_curves_sharing_few_points_do_not_enter_permutation_matching() {
let mut sk = Sketch::new();
let geom = vec![LinkGeom::Line(Vec2::new(1.0, 1.0), Vec2::new(2.0, 2.0)); 513];
let link = sk.add_link(LinkKind::Project, face(), &geom).unwrap();
assert_eq!(sk.link_points(&link).len(), 2);
let before = sk.clone();
let shifted = vec![LinkGeom::Line(Vec2::new(3.0, 3.0), Vec2::new(4.0, 4.0)); 513];
assert!(sk.update_link(&link, &shifted).is_err());
assert_eq!(sk.points, before.points);
assert_eq!(sk.curves, before.curves);
assert!(sk.link(&link).unwrap().lost);
}
#[test]
fn concentric_face_resize_keeps_inner_outer_circle_identity() {
let mut sk = Sketch::new();
let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 8.0), LinkGeom::Circle(Vec2::ZERO, 10.0)]).unwrap();
let ids: Vec<String> = sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect();
assert!(!sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 20.0), LinkGeom::Circle(Vec2::ZERO, 16.0)]).unwrap());
assert!(matches!(sk.curves[0].kind, CurveKind::Circle { r, .. } if r == 16.0));
assert!(matches!(sk.curves[1].kind, CurveKind::Circle { r, .. } if r == 20.0));
assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::<Vec<_>>());
// Coincident circles do not have a unique boundary identity.
let before = sk.clone();
assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 18.0), LinkGeom::Circle(Vec2::ZERO, 18.0)]).is_err());
assert_eq!(sk.points, before.points);
assert_eq!(sk.curves, before.curves);
}
#[test]
fn resized_dense_rim_matches_arc_centres_by_role_before_proximity() {
let mut sk = Sketch::new();
let geometry = |half_width: f64, x: f64| {
let mut out = square(half_width);
let c = Vec2::new(x, -16.5);
let left = c - Vec2::X * 3.0;
let right = c + Vec2::X * 3.0;
out.push(LinkGeom::Arc { c, a: right, b: left });
out.push(LinkGeom::Arc { c, a: left, b: right });
out
};
let link = sk.add_link(LinkKind::Project, face(), &geometry(38.0, -26.5)).unwrap();
let arcs: Vec<usize> = sk.link_curves(&link).into_iter().filter(|i| matches!(sk.curves[*i].kind, CurveKind::Arc { .. })).collect();
let ids: Vec<String> = arcs.iter().map(|i| sk.curves[*i].id.clone()).collect();
// The old centre is spatially closer in normalized space to a fresh endpoint.
// Both semicircles must still keep the same shared centre and logical endpoints.
assert!(!sk.update_link(&link, &geometry(43.0, -31.5)).unwrap());
for i in &arcs {
let CurveKind::Arc { c, a, b } = sk.curves[*i].kind else { panic!() };
assert_eq!(sk.points[c].pos, Vec2::new(-31.5, -16.5));
assert!((sk.points[a].pos.dist(sk.points[c].pos) - 3.0).abs() < 1e-9);
assert!((sk.points[b].pos.dist(sk.points[c].pos) - 3.0).abs() < 1e-9);
}
assert_eq!(ids, arcs.iter().map(|i| sk.curves[*i].id.clone()).collect::<Vec<_>>());
}
+47 -2
View File
@@ -9,7 +9,7 @@
//! gives the remaining degrees of freedom and which points are fully determined.
use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec2;
use solvecraft_geom::{Seg2, Vec2};
use crate::linalg::{Mat, determined_vars, solve as lin_solve};
use crate::model::{ConstraintKind, CurveKind, Sketch};
@@ -177,6 +177,51 @@ impl State<'_> {
_ => Vec::new(),
}
}
/// 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 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, .. }) => {
let center = self.p(*ci);
let u = self.p(*m) - center;
let a = u.len();
let r = self.radius_of(c);
if a <= 1e-12 || r <= 1e-12 {
return q.dist(center);
}
let delta = q - center;
let x = delta.dot(u) / (a * a);
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();
if p.len() < 2 {
return q.dist(p.first().copied().unwrap_or_default());
}
if *control {
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()
}
}
Some(CurveKind::Conic { a, b, apex, rho }) => {
vec![Seg2::Conic { a: self.p(*a), b: self.p(*b), apex: self.p(*apex), w: rho / (1.0 - rho).max(1e-9) }]
}
_ => Vec::new(),
};
segs.iter()
.map(|seg| {
let t = seg.closest_param(q);
let delta = q - seg.point_at(t);
let d = delta.len();
(d, if seg.derivative(t).cross(delta) >= 0.0 { d } else { -d })
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, signed)| signed)
.unwrap_or_else(|| crate::curves::polyline_signed_dist(&self.poly(c), q))
}
/// Radius of a circle or arc; an ellipse's minor radius.
fn radius_of(&self, c: usize) -> f64 {
match self.sk.curves.get(c).map(|x| &x.kind) {
@@ -281,7 +326,7 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
PointOnCurve { p, c } => {
let pp = s.p(p);
if s.is_freeform(c) {
out.push(crate::curves::polyline_signed_dist(&s.poly(c), pp));
out.push(s.freeform_dist(c, pp));
} else if s.is_line(c) {
let (a, b) = s.line(c);
out.push(line_dist(pp, a, b));