fix: a projected edge stays the edge that was picked when its body changes (#730)

A sketch's link to a projected edge now keeps the edge's persistent name and finds it by that name when the body changes, instead of taking whichever edge ends up nearest its point.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-13 12:06:13 -04:00
1 parent 4a989f6e2d
commit 58af8accfa
12 files changed
+365 -30

No files matched your search

+96 -19
View File
@@ -1,8 +1,9 @@
//! Resolving sketch links against the model: projecting edges, faces, body silhouettes, other
//! sketches and the origin onto a sketch plane, and intersecting bodies with it.
//!
//! Model geometry is found again on every recompute from the reference point stored in the
//! link (like fillet edges). Edge polylines from the tessellation lie exactly on their curves,
//! Model geometry is found again on every recompute: a face or an edge by the persistent name
//! its link keeps, anything else (and a link saved without a name) from the reference point
//! stored in the link. Edge polylines from the tessellation lie exactly on their curves,
//! so lines and circular arcs are recovered exactly by fitting; anything else (an ellipse from
//! a tilted circle, a spline edge) becomes a chain of lines.
@@ -35,6 +36,9 @@ pub struct Resolved {
/// Exact face selected during this resolution, before its closest point is
/// projected onto an edge shared with another face. Computed metadata only.
pub face_index: Option<usize>,
/// The edge an `Edge` reference resolved to (its index in the body's mesh), whose
/// persistent name the link keeps ([`resolved_edge_name`]). Computed metadata only.
pub edge_index: Option<usize>,
}
/// Resolutions of body-based references, keyed by the reference, the sketch plane and the
@@ -98,13 +102,9 @@ fn resolve_uncached(
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
let mut moved = false;
let mut face_index = None;
let mut edge_index = None;
let mut source = src.clone();
let mut wires: Vec<Vec<Vec3>> = Vec::new();
// Included curves that leave the sketch plane stay 3D.
let off_plane = |pts: &[Vec3]| {
let size = pts.iter().fold(1.0_f64, |m, p| m.max(p.len()));
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
};
let geom = match src {
LinkSource::Edge { body, at } => {
let (bi, mesh) = body_mesh(st, body, *at)?;
@@ -116,15 +116,8 @@ fn resolve_uncached(
moved = d > (size * 2e-3).max(1e-3);
let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone());
source = LinkSource::Edge { body: name, at: q };
let pts = mesh.edges.get(e).cloned().unwrap_or_default();
match kind {
LinkKind::Intersect => section_of_polyline(plane, &pts),
LinkKind::Include if off_plane(&pts) => {
wires.push(pts);
Vec::new()
}
_ => fit_world(plane, &pts),
}
edge_index = Some(e);
edge_geom(plane, kind, mesh.edges.get(e).cloned().unwrap_or_default(), &mut wires)
}
LinkSource::Face { body, at } => {
let (bi, mesh) = body_mesh(st, body, *at)?;
@@ -188,7 +181,7 @@ fn resolve_uncached(
}
LinkKind::Include => {
let pts: Vec<Vec3> = ss.sketch.polyline(ci).iter().map(|p| ss.plane.to_world(*p)).collect();
if off_plane(&pts) {
if off_plane(plane, &pts) {
wires.push(pts);
Vec::new()
} else {
@@ -273,7 +266,34 @@ fn resolve_uncached(
if geom.is_empty() && wires.is_empty() {
return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe())));
}
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index })
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index, edge_index })
}
/// Whether a curve (world points) leaves the sketch plane: included, it then stays 3D.
fn off_plane(plane: &Plane, pts: &[Vec3]) -> bool {
let size = pts.iter().fold(1.0_f64, |m, p| m.max(p.len()));
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
}
/// What a body edge (its points) gives a sketch: its section with the sketch plane, itself as a
/// 3D curve (into `wires`) when it is included and leaves the plane, else its projection.
fn edge_geom(plane: &Plane, kind: LinkKind, pts: Vec<Vec3>, wires: &mut Vec<Vec<Vec3>>) -> Vec<LinkGeom> {
match kind {
LinkKind::Intersect => section_of_polyline(plane, &pts),
LinkKind::Include if off_plane(plane, &pts) => {
wires.push(pts);
Vec::new()
}
_ => fit_world(plane, &pts),
}
}
/// The persistent name of the edge an `Edge` reference resolved to, for its link to keep and
/// be found by from then on. None on a mesh body, whose edges have no names.
pub fn resolved_edge_name(st: &ModelState, r: &Resolved) -> Option<String> {
let (LinkSource::Edge { body, .. }, Some(index)) = (&r.source, r.edge_index) else { return None };
let body = st.body(body).filter(|body| !body.body.is_mesh())?;
crate::naming::edge_names(body).get(index).filter(|name| !name.is_empty()).cloned()
}
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
@@ -282,6 +302,36 @@ fn lost(reference: &str) -> DocError {
DocError::Unknown(format!("{reference} no longer exists"))
}
/// The edge a link's stored name finds, and the body it is on: the link's body, or, when no
/// body has that name any more (it was joined into another), the body on which the name finds
/// the edge nearest the link's point.
fn locate_named_edge(st: &ModelState, body: &str, name: &str, at: Vec3) -> Option<(String, crate::naming::EdgeHit)> {
let hit = |b: &crate::ModelBody| crate::naming::locate_edge(b, name, at).ok().map(|hit| (b.name.clone(), hit));
match st.body(body) {
Some(b) => hit(b),
None => st.bodies.iter().filter(|b| !b.body.is_mesh()).filter_map(hit).min_by(|a, b| a.1.point.dist(at).total_cmp(&b.1.point.dist(at))),
}
}
/// An edge found by its persistent name: edge `index` of `body`, with the link's point `near`
/// moved onto it.
fn resolve_named_edge(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, near: Vec3) -> Result<Resolved> {
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
let pts = target.mesh().edges.get(index).cloned().unwrap_or_default();
let at = pts
.windows(2)
.filter_map(|w| Some(closest_on_segment(near, *w.first()?, *w.get(1)?)))
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
.ok_or_else(|| lost("named projection edge"))?;
let mut wires = Vec::new();
let geom = edge_geom(plane, kind, pts, &mut wires);
if geom.is_empty() && wires.is_empty() {
return Err(DocError::Invalid("named edge projects to nothing on this sketch plane".into()));
}
let source = LinkSource::Edge { body: body.into(), at };
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved: false, face_index: None, edge_index: Some(index) })
}
fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result<Resolved> {
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
let mesh = target.mesh();
@@ -305,7 +355,7 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
if geom.is_empty() {
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
}
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index, edge_index: None })
}
fn projection_face_edges(body: &crate::ModelBody, mesh: &Mesh, index: u32, plane: &Plane) -> Result<Vec<usize>> {
@@ -392,6 +442,7 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
source: LinkSource::Face { body: body.name.clone(), at: point },
moved: false,
face_index: if indices.len() == 1 { indices.first().copied() } else { None },
edge_index: None,
})
}
@@ -519,6 +570,22 @@ fn refresh_links_in(
continue;
}
}
// An edge the link keeps by name is that edge of its body, wherever it went. When no
// edge has the name any more the link is lost: no other edge is taken for it.
let mut named_edge = None;
if let (Some(name), LinkSource::Edge { body, at }) = (&l.edge_name, &source) {
match locate_named_edge(st, body, name, *at) {
Some((body, hit)) => {
warn.extend(hit.note(name).map(|note| format!("projected {note}")));
named_edge = Some((body, hit.edge, *at));
}
None => {
sk.set_link_lost(&l.id, true);
warn.push(format!("projected reference lost: edge `{name}` no longer exists; last geometry retained"));
continue;
}
}
}
let resolved = if l.auto_face {
anchor
.filter(|anchor| {
@@ -528,11 +595,21 @@ fn refresh_links_in(
.and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor))
} else if let Some((body, index, at)) = named_face {
resolve_named_face(st, plane, l.kind, &body, index, at)
} else if let Some((body, index, at)) = named_edge {
resolve_named_edge(st, plane, l.kind, &body, index, at)
} else {
resolve(doc, vals, st, plane, l.kind, &source)
};
match resolved {
Ok(r) => {
// An edge link saved without a name takes the name of the edge it was found
// on. A sketch that is edited stores it; evaluation leaves the design as it is.
if l.edge_name.is_none()
&& let Some(name) = resolved_edge_name(st, &r)
&& let Some(link) = sk.links.iter_mut().find(|link| link.id == l.id)
{
link.edge_name = Some(name);
}
sk.set_link_source(&l.id, r.source);
let _ = sk.set_link_wires(&l.id, &r.wires);
match sk.update_link(&l.id, &r.geom) {