Files
solidcraft-upstream/crates/sketch/src/link.rs
T
Iraklis KourtisandClaude Opus 5.5 fe4d171e22 feat(sketch): live drag that keeps its solution, ended by sketch.drag_end (#341)
* feat(sketch): constructive solving ahead of the numeric solver

Part 1 of splitting #251: the sketch crate only. A graph-constructive front
end (Owen 1991; Fudos & Hoffmann 1997) places what it can by ruler-and-compass
loci and cluster decomposition, Levenberg-Marquardt finishes and verifies the
rest, and the rank analysis stays authoritative for degrees of freedom. Solves
can follow a reference (the previous solution), so edits and drags keep their
branch instead of flipping.

New API: solve_with (hold, reference, analysis, budget) and
solve_after_value_edit; solve and solve_holding keep their signatures as
wrappers, so no caller changes here.

When the seed moves an arc's centre, the arc's unconstructed ends start carried
along (the arc translates) instead of left behind for LM to turn; found by the
reference test 20-slot-constrained.

Refs #203

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

* feat(doc): keep a sketch's solved state across value edits

Part 2 of splitting #251 (builds on part 1, the sketch crate):
- Sketch edits solve for the document's values continuing from the stored
  solved state (Document::solve_sketch), so a dimension edit keeps the
  sketch's branch instead of re-solving from scratch.
- A value edit (parameters, a configuration, the units) that would make a
  sketch that solved before fail or turn over is refused with the
  conflicting constraints named; otherwise the solved sketches are written
  back into the document (Model::adopt_solved_sketches), which changes what
  saved designs store.
- Later features are keyed by a sketch's solved geometry (to a 1e-6 mm
  quantum), so setting a value back reuses their earlier results.
- Solved sketches in a model state are shared (Arc) between states.

Refs #203

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

* feat(sketch): live drag that keeps its solution, ended by sketch.drag_end

Part 3 of splitting #251 (builds on part 2):
- sketch.drag follows a long move in short held steps, so the geometry keeps
  its branch, and reports how far it got (reached, at).
- live: true runs one frame of an interactive drag: the sketch is solved
  once and shown, later features keep their last result (marked stale)
  until sketch.drag_end or any command that reads the model evaluates it
  (CommandSpec::live, reads_model, doc_only).
- sketch.drag_end adds the constraint the drag let go on and evaluates the
  model once.
- Undo and redo evaluate a new document once (execute does) instead of
  twice.
- The viewport drives drags through live frames and drag_end; dimension
  text drags start only off sketch geometry.

Refs #203

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:45:48 -04:00

876 lines
38 KiB
Rust

//! Linked (associative) reference geometry: curves and points projected into a sketch from
//! model edges, faces, bodies, other sketches and the origin. The sketch only stores the link
//! (what it refers to) and the geometry it produced last time; resolving a link against the
//! model happens in the document crate, which hands the new geometry to [`Sketch::update_link`].
//!
//! Linked points are fixed and linked circles keep their radius, so the solver treats them as
//! constants; they still bound profiles and take constraints and dimensions like any curve.
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Vec2, Vec3};
use crate::model::{Curve, CurveKind, Result, Sketch, SketchError, in_range, radius_in_range};
/// 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;
/// How the geometry was brought in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LinkKind {
/// Projected onto the sketch plane along its normal.
#[default]
Project,
/// Included as is (curves in the sketch plane; others are flattened onto it).
Include,
/// Section curves of a body or face with the sketch plane.
Intersect,
/// Text outlines (drawn like normal geometry; edit the text to change them).
Text,
}
/// What a link refers to. Model geometry is referenced by a point on it plus the body name and
/// re-found on every recompute (like fillet edges), so it follows upstream edits.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LinkSource {
/// A body edge through `at`.
Edge { body: String, at: Vec3 },
/// The boundary loops of the body face through `at` (Intersect: the face's section).
Face { body: String, at: Vec3 },
/// A whole body: its silhouette (Project) or its section (Intersect).
Body { body: String },
/// A body vertex at `at`.
Vertex { body: String, at: Vec3 },
/// A curve of another sketch.
SketchCurve { sketch: u64, curve: String },
/// A point of another sketch.
SketchPoint { sketch: u64, point: String },
/// The model origin.
Origin,
/// An origin axis (`X`, `Y`, `Z`).
Axis { name: String },
/// An origin plane (`XY`, `XZ`, `YZ`) or a construction plane (by name): its trace on the
/// sketch plane.
Plane { name: String },
/// A curve of a sketch projected onto a body face (through `at`) along the normal of the
/// sketch that holds the link: a 3D curve.
OnSurface { sketch: u64, curve: String, body: String, at: Vec3 },
/// Where two bodies or faces (`Body` / `Face` sources) meet: 3D curves.
Intersection { a: Box<LinkSource>, b: Box<LinkSource> },
/// An isoparametric curve of the face through `at`: `dir` is `u` (around a curved face),
/// `v` (along it) or an explicit direction `[x, y, z]` (as text).
Iso { body: String, at: Vec3, dir: String },
/// The outline of a body spun about an axis (world line), laid into the sketch plane.
Spun { body: String, origin: Vec3, dir: Vec3 },
/// Text: baseline start `at` (sketch coordinates), capital height and angle (radians).
Text {
text: String,
at: Vec2,
height: f64,
#[serde(default)]
angle: f64,
},
}
impl LinkSource {
/// Short description for messages and the browser.
pub fn describe(&self) -> String {
match self {
LinkSource::Edge { body, .. } => format!("edge of {body}"),
LinkSource::Face { body, .. } => format!("face of {body}"),
LinkSource::Body { body } => body.clone(),
LinkSource::Vertex { body, .. } => format!("vertex of {body}"),
LinkSource::SketchCurve { curve, .. } => format!("sketch curve {curve}"),
LinkSource::SketchPoint { point, .. } => format!("sketch point {point}"),
LinkSource::Origin => "origin".into(),
LinkSource::Axis { name } => format!("{name} axis"),
LinkSource::Plane { name } => format!("{name} plane"),
LinkSource::OnSurface { curve, .. } => format!("{curve} on a face"),
LinkSource::Intersection { a, b } => format!("intersection of {} and {}", a.describe(), b.describe()),
LinkSource::Spun { body, .. } => format!("spun profile of {body}"),
LinkSource::Iso { body, .. } => format!("isoparametric curve of {body}"),
LinkSource::Text { text, .. } => format!("text \"{}\"", text.chars().take(20).collect::<String>()),
}
}
}
/// One link and its state.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Link {
pub id: String,
#[serde(default)]
pub kind: LinkKind,
pub source: LinkSource,
/// 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,
}
/// Geometry a link produces, in sketch coordinates.
#[derive(Clone, Debug, PartialEq)]
pub enum LinkGeom {
Point(Vec2),
Line(Vec2, Vec2),
Circle(Vec2, f64),
/// Counter-clockwise arc around `c` from `a` to `b`.
Arc {
c: Vec2,
a: Vec2,
b: Vec2,
},
/// Conic from `a` to `b` with control `apex` (rho 0.5: a quadratic Bézier).
Conic {
a: Vec2,
apex: Vec2,
b: Vec2,
rho: f64,
},
/// Full ellipse: centre, end of the major axis, minor radius.
Ellipse {
c: Vec2,
major: Vec2,
minor: f64,
},
/// Spline through (`control == false`) or over (`control`) the points.
Spline {
pts: Vec<Vec2>,
control: bool,
degree: u8,
periodic: bool,
},
}
impl LinkGeom {
fn finite(&self) -> bool {
let ok = |p: &Vec2| in_range(*p);
match self {
LinkGeom::Point(p) => ok(p),
LinkGeom::Line(a, b) => ok(a) && ok(b) && a.dist(*b) > MERGE_TOL,
LinkGeom::Circle(c, r) => ok(c) && radius_in_range(*r),
LinkGeom::Arc { c, a, b } => ok(c) && ok(a) && ok(b) && c.dist(*a) > 1e-9 && a.dist(*b) > MERGE_TOL,
LinkGeom::Conic { a, apex, b, rho } => ok(a) && ok(apex) && ok(b) && a.dist(*b) > MERGE_TOL && *rho > 1e-6 && *rho < 1.0 - 1e-6,
LinkGeom::Ellipse { c, major, minor } => ok(c) && ok(major) && c.dist(*major) > 1e-9 && radius_in_range(*minor),
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)
}
}
}
}
/// Link geometry as merged points plus curves over them (indices into the point list).
struct Layout {
pts: Vec<Vec2>,
/// Free-standing points (not owned by a curve).
lone: Vec<usize>,
curves: Vec<LCurve>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum LCurve {
Line(usize, usize),
Circle(usize, f64),
Arc(usize, usize, usize),
Conic(usize, usize, usize, f64),
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, bool),
}
fn layout(geom: &[LinkGeom]) -> Result<Layout> {
if geom.len() > MAX_LINK_ENTITIES {
return Err(SketchError::TooLarge);
}
let mut l = Layout { pts: Vec::new(), lone: Vec::new(), curves: Vec::new() };
fn pt(pts: &mut Vec<Vec2>, p: Vec2) -> usize {
if let Some(i) = pts.iter().position(|q| q.dist(p) <= MERGE_TOL) {
return i;
}
pts.push(p);
pts.len() - 1
}
for g in geom.iter().filter(|g| g.finite()) {
match g.clone() {
LinkGeom::Point(p) => {
let i = pt(&mut l.pts, p);
if !l.lone.contains(&i) {
l.lone.push(i);
}
}
LinkGeom::Line(a, b) => {
let (a, b) = (pt(&mut l.pts, a), pt(&mut l.pts, b));
if a != b {
l.curves.push(LCurve::Line(a, b));
}
}
LinkGeom::Circle(c, r) => {
let c = pt(&mut l.pts, c);
l.curves.push(LCurve::Circle(c, r));
}
LinkGeom::Arc { c, a, b } => {
let r = c.dist(a);
let b = c + (b - c).normalized().unwrap_or(Vec2::X) * r;
let (c, a, b) = (pt(&mut l.pts, c), pt(&mut l.pts, a), pt(&mut l.pts, b));
if a != b && c != a {
l.curves.push(LCurve::Arc(c, a, b));
}
}
LinkGeom::Conic { a, apex, b, rho } => {
let (a, b, x) = (pt(&mut l.pts, a), pt(&mut l.pts, b), pt(&mut l.pts, apex));
if a != b && x != a && x != b {
l.curves.push(LCurve::Conic(a, b, x, rho));
}
}
LinkGeom::Ellipse { c, major, minor } => {
let (c, m) = (pt(&mut l.pts, c), pt(&mut l.pts, major));
if c != m {
l.curves.push(LCurve::Ellipse(c, m, minor));
}
}
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, periodic));
}
}
}
// Lone points that a curve also uses are not lone.
let used = |i: usize, cs: &[LCurve]| {
cs.iter().any(|c| match *c {
LCurve::Line(a, b) => a == i || b == i,
LCurve::Circle(c, _) => c == i,
LCurve::Arc(c, a, b) => c == i || a == i || b == i,
LCurve::Conic(a, b, x, _) => a == i || b == i || x == i,
LCurve::Ellipse(c, m, _) => c == i || m == i,
LCurve::Spline(a, b, f, n, ..) => a == i || b == i || (i >= f && i < f + n),
})
};
let curves = l.curves.clone();
l.lone.retain(|i| !used(*i, &curves));
Ok(l)
}
fn same_shape(a: &LCurve, b: &LCurve) -> bool {
matches!(
(a, b),
(LCurve::Line(..), LCurve::Line(..))
| (LCurve::Circle(..), LCurve::Circle(..))
| (LCurve::Arc(..), LCurve::Arc(..))
| (LCurve::Conic(..), LCurve::Conic(..))
| (LCurve::Ellipse(..), LCurve::Ellipse(..))
| (LCurve::Spline(..), LCurve::Spline(..))
)
}
/// Do two curve sets describe the same geometry, regardless of the order the curves come in or
/// the direction a loop was walked? Lines match with either end first; circles by centre and
/// radius; arcs by centre and their (start, end) pair; conics by all their points and rho.
fn same_geometry(a: &[(LCurve, Vec<Vec2>)], b: &[(LCurve, Vec<Vec2>)]) -> bool {
const TOL: f64 = 1e-6;
let near = |p: Vec2, q: Vec2| p.dist(q) <= TOL * (1.0 + p.len().max(q.len()));
let close = |x: f64, y: f64| (x - y).abs() <= TOL * (1.0 + x.abs().max(y.abs()));
let matches = |(ca, pa): &(LCurve, Vec<Vec2>), (cb, pb): &(LCurve, Vec<Vec2>)| -> bool {
if pa.len() != pb.len() {
return false;
}
let all = || pa.iter().zip(pb).all(|(p, q)| near(*p, *q));
match (ca, cb) {
(LCurve::Line(..), LCurve::Line(..)) => {
all() || matches!((pa.as_slice(), pb.as_slice()), ([a0, a1], [b0, b1]) if near(*a0, *b1) && near(*a1, *b0))
}
(LCurve::Circle(_, ra), LCurve::Circle(_, rb)) => all() && close(*ra, *rb),
(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, pa), LCurve::Spline(.., cb, db, pb)) => ca == cb && da == db && pa == pb && all(),
_ => false,
}
};
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);
match hit.and_then(|j| used.get_mut(j)) {
Some(u) => {
*u = true;
true
}
None => false,
}
})
}
/// The point indices a layout curve uses, in order.
fn curve_points(c: &LCurve) -> Vec<usize> {
match *c {
LCurve::Line(a, b) => vec![a, b],
LCurve::Circle(c, _) => vec![c],
LCurve::Arc(c, a, b) => vec![c, a, b],
LCurve::Conic(a, b, x, _) => vec![a, b, x],
LCurve::Ellipse(c, m, _) => vec![c, m],
LCurve::Spline(a, b, f, n, ..) => {
let mut v = vec![a];
v.extend(f..f + n);
v.push(b);
v
}
}
}
/// 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)
}
/// Curves owned by a link, in creation order.
pub fn link_curves(&self, id: &str) -> Vec<usize> {
self.curves.iter().enumerate().filter(|(_, c)| c.link.as_deref() == Some(id)).map(|(i, _)| i).collect()
}
/// Points owned by a link, in creation order.
pub fn link_points(&self, id: &str) -> Vec<usize> {
self.points.iter().enumerate().filter(|(_, p)| p.link.as_deref() == Some(id)).map(|(i, _)| i).collect()
}
/// Is this curve linked reference geometry?
pub fn is_linked_curve(&self, ci: usize) -> bool {
self.curves.get(ci).is_some_and(|c| c.link.is_some())
}
/// Is this curve text outline (linked, but drawn like normal geometry)?
pub fn is_text_curve(&self, ci: usize) -> bool {
self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.kind == LinkKind::Text)
}
/// Is the curve's link lost (its reference no longer found)?
pub fn is_lost_curve(&self, ci: usize) -> bool {
self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.lost)
}
/// Add linked geometry. Returns the link id; the new curve and point ids are
/// [`Sketch::link_curves`] / [`Sketch::link_points`] of it.
pub fn add_link(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom]) -> Result<String> {
let lay = layout(geom)?;
if lay.curves.is_empty() && lay.lone.is_empty() {
return Err(SketchError::Invalid("the reference projects to nothing on this sketch plane".into()));
}
if self.points.len() + lay.pts.len() > crate::model::MAX_POINTS {
return Err(SketchError::TooLarge);
}
let id = self.fresh("j")?;
self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false });
self.build_link(&id, &lay)?;
Ok(id)
}
fn build_link(&mut self, id: &str, lay: &Layout) -> Result<()> {
let mut idx: Vec<Option<usize>> = vec![None; lay.pts.len()];
let mut point = |sk: &mut Sketch, li: usize| -> Result<usize> {
if let Some(Some(i)) = idx.get(li) {
return Ok(*i);
}
let p = lay.pts.get(li).copied().ok_or_else(|| SketchError::Invalid("link point".into()))?;
let i = sk.add_point(p, None)?;
if let Some(sp) = sk.points.get_mut(i) {
sp.fixed = true;
sp.link = Some(id.to_string());
}
if let Some(slot) = idx.get_mut(li) {
*slot = Some(i);
}
Ok(i)
};
for c in &lay.curves {
let (kind, prefix) = match *c {
LCurve::Line(a, b) => (CurveKind::Line { a: point(self, a)?, b: point(self, b)? }, "l"),
LCurve::Circle(c, r) => (CurveKind::Circle { c: point(self, c)?, r }, "c"),
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, 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, periodic }, "s")
}
};
let cid = self.fresh(prefix)?;
self.curves.push(Curve {
id: cid,
kind,
construction: false,
reversed: false,
link: Some(id.to_string()),
centerline: false,
fixed: false,
});
}
for li in &lay.lone {
point(self, *li)?;
}
Ok(())
}
/// Replace a link's geometry with a newly resolved one. When the shape is the same (same
/// curve kinds sharing the same points) the entities keep their ids and only move, so
/// constraints and dimensions on them survive; otherwise the link is rebuilt. Returns
/// `true` when it was rebuilt.
pub fn update_link(&mut self, id: &str, geom: &[LinkGeom]) -> Result<bool> {
if self.link(id).is_none() {
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.
let local = |p: usize| points.iter().position(|q| *q == p);
let cur: Option<Vec<LCurve>> = curves
.iter()
.map(|ci| match self.curves.get(*ci).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) => Some(LCurve::Line(local(*a)?, local(*b)?)),
Some(CurveKind::Circle { c, r }) => Some(LCurve::Circle(local(*c)?, *r)),
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, 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<_>>()?;
let f = inner.first().copied().unwrap_or(0);
// Interior points were made one after another.
if inner.iter().enumerate().any(|(k, x)| *x != f + k) {
return None;
}
Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree, *periodic))
}
_ => None,
})
.collect();
// The new layout numbers points in first-use order: curves first, then lone points,
// exactly as `build_link` creates them.
let mut order: Vec<usize> = Vec::new();
let mut visit = |i: usize| {
if !order.contains(&i) {
order.push(i);
}
};
for c in &lay.curves {
match *c {
LCurve::Line(a, b) => {
visit(a);
visit(b);
}
LCurve::Circle(c, _) => visit(c),
LCurve::Arc(c, a, b) => {
visit(c);
visit(a);
visit(b);
}
LCurve::Conic(a, b, x, _) => {
visit(a);
visit(b);
visit(x);
}
LCurve::Ellipse(c, m, _) => {
visit(c);
visit(m);
}
LCurve::Spline(a, b, f, n, ..) => {
visit(a);
for i in f..f + n {
visit(i);
}
visit(b);
}
}
}
for i in &lay.lone {
visit(*i);
}
let renum = |i: usize| order.iter().position(|q| *q == i).unwrap_or(usize::MAX);
let new: Vec<LCurve> = lay
.curves
.iter()
.map(|c| match *c {
LCurve::Line(a, b) => LCurve::Line(renum(a), renum(b)),
LCurve::Circle(c, r) => LCurve::Circle(renum(c), r),
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, periodic) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d, periodic),
})
.collect();
let same = match &cur {
Some(cur) => {
cur.len() == new.len()
&& order.len() == points.len()
&& cur.iter().zip(&new).all(|(a, b)| {
same_shape(a, b)
&& match (a, b) {
(LCurve::Line(a0, a1), LCurve::Line(b0, b1)) => a0 == b0 && a1 == b1,
(LCurve::Circle(a0, _), LCurve::Circle(b0, _)) => a0 == b0,
(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, 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,
}
})
}
None => false,
};
if let Some(l) = self.links.iter_mut().find(|l| l.id == id) {
l.lost = 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 {
let pos = |pi: usize| self.points.get(pi).map(|p| p.pos);
let old: Option<Vec<(LCurve, Vec<Vec2>)>> = cur
.iter()
.map(|c| curve_points(c).into_iter().map(|li| points.get(li).and_then(|pi| pos(*pi))).collect::<Option<Vec<_>>>().map(|ps| (*c, ps)))
.collect();
let fresh: Option<Vec<(LCurve, Vec<Vec2>)>> = lay
.curves
.iter()
.map(|c| curve_points(c).into_iter().map(|li| lay.pts.get(li).copied()).collect::<Option<Vec<_>>>().map(|ps| (*c, ps)))
.collect();
let lone_old: Vec<Vec2> = points.iter().filter_map(|pi| pos(*pi)).collect();
if let (Some(old), Some(fresh)) = (old, fresh)
&& lay.lone.is_empty()
&& points.len() == lay.pts.len()
&& lone_old.len() == points.len()
&& same_geometry(&old, &fresh)
{
return Ok(false);
}
}
// A resized face can enumerate the same boundary in another order. Match the
// shared-point graph in normalized sketch space before discarding its identity.
if let Some(cur) = &cur
&& 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> {
let lo = ps.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
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 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)
&& 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
&& candidates.get(1).is_none_or(|(_, second)| *d < *second * 0.5)
&& !mapping.contains(i)
{
mapping.push(*i);
} else {
break;
}
}
let mut used = Vec::new();
let mut curve_map = Vec::new();
if mapping.len() == points.len() {
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 candidates: Vec<usize> = lay
.curves
.iter()
.enumerate()
.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
})
.then_some(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);
} else {
break;
}
}
}
if curve_map.len() == cur.len() && mapping.len() == points.len() {
for (pi, li) in points.iter().zip(mapping) {
if let (Some(p), Some(q)) = (self.points.get_mut(*pi), lay.pts.get(li)) {
p.pos = *q;
}
}
for (ci, li) in curves.iter().zip(curve_map) {
if let (Some(Curve { kind: CurveKind::Circle { r, .. }, .. }), Some(LCurve::Circle(_, value))) =
(self.curves.get_mut(*ci), lay.curves.get(li))
{
*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)?;
Ok(true)
}
fn drop_link_entities(&mut self, id: &str) {
let curves = self.link_curves(id);
let keep = self.links.clone();
self.remove_curves(&curves);
let pts = self.link_points(id);
self.remove_points(&pts);
// Removal prunes empty links; this one stays.
self.links = keep;
}
/// Mark a link as lost (its reference was not found); its geometry stays as it was.
pub fn set_link_lost(&mut self, id: &str, lost: bool) {
if let Some(l) = self.links.iter_mut().find(|l| l.id == id) {
l.lost = lost;
}
}
/// Store an updated reference (e.g. the point on an edge after an upstream edit).
pub fn set_link_source(&mut self, id: &str, source: LinkSource) {
if let Some(l) = self.links.iter_mut().find(|l| l.id == id) {
l.source = source;
}
}
/// Turn linked geometry into normal sketch geometry: the link goes, the curves and points
/// stay where they are and become free.
pub fn break_link(&mut self, id: &str) -> Result<()> {
if self.link(id).is_none() {
return Err(SketchError::Unknown(id.into()));
}
for c in &mut self.curves {
if c.link.as_deref() == Some(id) {
c.link = None;
}
}
for p in &mut self.points {
if p.link.as_deref() == Some(id) {
p.link = None;
p.fixed = false;
}
}
for w in &mut self.wires {
if w.link.as_deref() == Some(id) {
w.link = None;
}
}
self.links.retain(|l| l.id != id);
Ok(())
}
/// Remove links that no longer own any curve or point.
pub(crate) fn prune_links(&mut self) {
if self.links.is_empty() {
return;
}
let used = |id: &str, sk: &Sketch| {
sk.curves.iter().any(|c| c.link.as_deref() == Some(id))
|| sk.points.iter().any(|p| p.link.as_deref() == Some(id))
|| sk.wires.iter().any(|w| w.link.as_deref() == Some(id))
};
let keep: Vec<bool> = self.links.iter().map(|l| used(&l.id, self)).collect();
let mut k = keep.iter();
self.links.retain(|_| k.next().copied().unwrap_or(true));
}
}
#[cfg(test)]
#[path = "link_identity_tests.rs"]
mod identity_tests;