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

+1 -1
View File
@@ -392,7 +392,7 @@ impl crate::Feature {
}
for link in &mut sketch.links {
remap_link(&mut link.source, ids, names, bodies, 0);
if let Some(n) = &mut link.face_name {
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
*n = remap_topology(n, ids, bodies);
}
}
+4
View File
@@ -85,6 +85,10 @@
//! edges of a solid or shelled body that become bends). A conversion without `bends` is the
//! plate conversion it always was and saves as before; 0.8.0 would drop the bend edges and
//! refuse the body as no plate.
//! A sketch's link to a projected edge can also keep the edge's persistent name (`edge_name`,
//! beside `face_name`) and is then found by it, wherever the edge went. A link without it is
//! the edge nearest its point, as it always was, and takes the name when its sketch is next
//! edited.
//! A fillet's `fit` can be `taper` (the radius follows the room along each edge it doesn't
//! fit). Fillets with `exact` or `reduce` save as before; 0.8.0 would refuse the unknown fit.
+8 -6
View File
@@ -30,6 +30,8 @@ pub fn legacy_name(name: &str) -> Option<&str> {
/// Where a stored edge name was found.
#[derive(Clone, Copy, Debug)]
pub struct EdgeHit {
/// The edge (its index in the body's mesh).
pub edge: usize,
/// A point on the edge: the reference's own point when it lies on it, else the edge's middle.
pub point: Vec3,
/// The named edge is not one edge any more: the piece nearest the point was taken.
@@ -62,7 +64,7 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
let exact = |i: usize| -> Result<EdgeHit, String> {
let on = edge_near(b, &m, i, near);
mid(i)
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
.map(|p| EdgeHit { edge: i, point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
.ok_or_else(|| "edge".into())
};
if let Some(i) = names.iter().position(|n| n == name) {
@@ -84,7 +86,7 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
.iter()
.filter_map(|i| mid(*i).map(|p| (p, *i)))
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
.map(|(p, i)| EdgeHit { point: p, split: true, verified: verified(i), on_point: false, legacy: false })
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: verified(i), on_point: false, legacy: false })
.ok_or_else(|| format!("edge `{name}` no longer exists"))
}
@@ -137,15 +139,15 @@ fn legacy_locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit,
if let Some(i) = names.iter().position(|n| n == name) {
let on = edge_near(b, &m, i, near);
return edge_mid(&m, i)
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
.map(|p| EdgeHit { edge: i, point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
.ok_or(());
}
let stem = strip_edge_piece(name);
(0..names.len())
.filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem))
.filter_map(|i| edge_mid(&m, i))
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
.map(|p| EdgeHit { point: p, split: true, verified: false, on_point: false, legacy: true })
.filter_map(|i| edge_mid(&m, i).map(|p| (p, i)))
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: false, on_point: false, legacy: true })
.ok_or(())
}
+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) {
@@ -150,7 +150,10 @@ fn wire_frame_include_other_component_refresh_break_lost_and_reopen() {
assert_eq!(s.doc.to_json(), before);
// Lost references retain the last coordinates and explicit marker, with no guessing.
let sk = s.doc_mut().sketch_mut(sketch).unwrap();
sk.links.iter_mut().find(|l| l.id == link).unwrap().source = solidcraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
let entry = sk.links.iter_mut().find(|l| l.id == link).unwrap();
entry.source = solidcraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
// The link is the edge it names; without the name it is the edge nearest its point.
entry.edge_name = None;
run(&mut s, "sketch.line", json!({"points":[[2,0],[3,0]]}));
let sk = s.doc.sketch(sketch).unwrap();
assert!(sk.link(&link).unwrap().lost);
+7
View File
@@ -226,9 +226,12 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
} else {
None
};
// The link is the edge that was picked, not whichever is nearest the picked point later.
let edge_name = solidcraft_doc::project::resolved_edge_name(&st, &r);
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
l.face_name = name;
l.edge_name = edge_name;
}
Ok(link)
}
@@ -568,6 +571,10 @@ mod tests;
#[path = "sketch_project_side_tests.rs"]
mod side_tests;
#[cfg(test)]
#[path = "sketch_project_edge_tests.rs"]
mod edge_tests;
#[cfg(test)]
#[path = "sketch_model_plane_tests.rs"]
mod model_plane_tests;
@@ -0,0 +1,228 @@
//! A projected edge stays the edge that was picked: its link keeps the edge's persistent name,
//! so the edge is found by that name wherever a change of the body takes it, and no other edge
//! is taken for it when it is split or gone.
use serde_json::{Value, json};
use solidcraft_geom::{Vec2, Vec3};
use solidcraft_sketch::{Link, LinkSource, Shape};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// A plate `width` (100) x 50 x 20 with a corner at the origin: body `Body1`.
fn plate(s: &mut Session) {
run(s, "parameters.add", json!({"name": "width", "expression": "100 mm"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "width"}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": "Base", "distance": "20 mm", "name": "Plate"}));
}
/// Sketch `Top` on the plate's top (z = 20) with the top edge of the plate's right-hand side,
/// from (100, 0, 20) to (100, 50, 20), projected into it: picked at `y` along it.
fn project_right_edge(s: &mut Session, y: f64) {
run(s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "Top"}));
run(s, "sketch.project", json!({"refs": [{"edge": [100, y, 20], "body": "Body1"}]}));
run(s, "sketch.finish", json!({}));
}
fn set_width(s: &mut Session, width: f64) {
run(s, "parameters.change", json!({"name": "width", "expression": format!("{width} mm")}));
}
/// What sketch `Top` has to report.
fn trouble(s: &Session) -> String {
let r = s.model.result(s.doc.find_feature("Top").unwrap().id).unwrap();
format!("{}{}", r.error.clone().unwrap_or_default(), r.warning.clone().unwrap_or_default())
}
/// The link of sketch `Top` as the model has it.
fn link(s: &Session) -> Link {
s.model.state().sketch(s.doc.find_feature("Top").unwrap().id).unwrap().sketch.links[0].clone()
}
/// The link of sketch `Top` as the design stores it.
fn stored(s: &Session) -> Link {
s.doc.sketch(s.doc.find_feature("Top").unwrap().id).unwrap().links[0].clone()
}
/// The ends of the line the link of sketch `Top` gives.
fn projected(s: &Session) -> (Vec2, Vec2) {
let st = s.model.state();
let sk = &st.sketch(s.doc.find_feature("Top").unwrap().id).unwrap().sketch;
match sk.shape(sk.link_curves(&sk.links[0].id)[0]) {
Some(Shape::Line { a, b }) => (a, b),
other => panic!("not a line: {other:?}"),
}
}
/// The projected line runs along y at `x`, between `y0` and `y1`.
#[track_caller]
fn assert_line(s: &Session, x: f64, (y0, y1): (f64, f64), when: &str) {
let (a, b) = projected(s);
let ends = (a.y.min(b.y), a.y.max(b.y));
assert!(
(a.x - x).abs() < 1e-6 && (b.x - x).abs() < 1e-6 && (ends.0 - y0).abs() < 1e-6 && (ends.1 - y1).abs() < 1e-6,
"{when}: expected the line x = {x}, y = {y0}..{y1}: {a:?} {b:?}"
);
}
/// The name of the edge of `Body1` whose middle is `mid`.
fn edge_named_at(s: &mut Session, mid: [f64; 3]) -> String {
let edges = run(s, "model.edges", json!({"body": "Body1"}));
let at = |e: &Value| Vec3::new(e["mid"][0].as_f64().unwrap(), e["mid"][1].as_f64().unwrap(), e["mid"][2].as_f64().unwrap());
let edge = edges["edges"].as_array().unwrap().iter().find(|e| at(e).dist(Vec3::new(mid[0], mid[1], mid[2])) < 1e-6).unwrap();
edge["name"].as_str().unwrap().to_string()
}
/// The picked point (100, 25, 20) is 25 from the front and back edges of the top: wider than
/// 125 they are nearer to it than the picked edge is. At 60 the picked edge is 40 from it,
/// further than a reference found by its point is looked for (a quarter of the body's diagonal,
/// 20.2). The link follows the edge by its name through all of them.
#[test]
fn a_projected_edge_stays_that_edge_when_the_body_changes_size() {
let mut s = Session::default();
plate(&mut s);
project_right_edge(&mut s, 25.0);
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
let name = edge_named_at(&mut s, [100.0, 25.0, 20.0]);
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
for width in [150.0, 60.0, 400.0] {
set_width(&mut s, width);
assert_eq!(trouble(&s), "", "width {width}");
assert_line(&s, width, (0.0, 50.0), &format!("width {width}"));
assert!(!link(&s).lost, "width {width}");
}
// Saved and opened again.
let opened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(stored(&opened).edge_name.as_deref(), Some(name.as_str()));
assert_line(&opened, 400.0, (0.0, 50.0), "opened");
// Undo and redo of the change.
run(&mut s, "edit.undo", json!({}));
assert_line(&s, 60.0, (0.0, 50.0), "undone");
run(&mut s, "edit.redo", json!({}));
assert_line(&s, 400.0, (0.0, 50.0), "redone");
// Editing the sketch keeps the edge, and stores the link's point where the edge is now.
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(trouble(&s), "");
assert_line(&s, 400.0, (0.0, 50.0), "edited");
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
assert!(matches!(&stored(&s).source, LinkSource::Edge { body, at } if body == "Body1" && at.dist(Vec3::new(400.0, 25.0, 20.0)) < 1e-6));
}
/// A slot cut through the plate from x = 80 to 120, y = 20 to 30 leaves the picked edge in two
/// pieces, y = 0..20 and y = 30..50. The link, picked at y = 10, gives the piece it was picked
/// on and says the edge was split; without the slot it is the whole edge again.
#[test]
fn a_projected_edge_that_was_split_gives_its_nearest_piece_and_says_so() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [80, 20], "p1": [120, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": "20 mm", "operation": "cut", "name": "Slot"}));
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
project_right_edge(&mut s, 10.0);
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": false}));
assert_line(&s, 100.0, (0.0, 20.0), "split");
assert!(trouble(&s).contains("was split"), "{}", trouble(&s));
assert!(!link(&s).lost);
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
assert_line(&s, 100.0, (0.0, 50.0), "whole again");
assert_eq!(trouble(&s), "");
}
/// A fillet of radius 4 takes the picked edge away. Its two tangent edges are 4 from the picked
/// point, the nearest edges there are now: the link is lost and keeps its line, it takes
/// neither of them. Without the fillet the link has its edge again.
#[test]
fn a_projected_edge_that_is_gone_is_lost_not_replaced() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "solid.fillet", json!({"edges": [[100, 25, 20]], "radius": "4 mm", "name": "Round"}));
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
project_right_edge(&mut s, 25.0);
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": false}));
let said = trouble(&s);
assert!(said.contains("projected reference lost: edge") && said.contains("no longer exists"), "{said}");
assert!(link(&s).lost);
assert_line(&s, 100.0, (0.0, 50.0), "the last geometry is kept");
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
assert_eq!(trouble(&s), "");
assert!(!link(&s).lost);
assert_line(&s, 100.0, (0.0, 50.0), "found again");
}
/// A boss 20 x 20 x 10 made as its own body on the plate and joined into it afterwards: no body
/// has the link's body name any more. The join left the boss's top edge from (30, 10, 30) to
/// (30, 30, 30) as it was, and the link finds it by its name on the body that has it now.
#[test]
fn a_projected_edge_whose_body_was_joined_into_another_is_found_there() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "BossSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "BossSketch", "distance": "10 mm", "operation": "new", "name": "Boss", "body_name": "Boss"}));
run(&mut s, "solid.combine", json!({"target": "Body1", "tools": ["Boss"], "operation": "join"}));
run(&mut s, "timeline.suppress", json!({"feature": "Combine1", "suppressed": true}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "30 mm", "name": "Top"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [30, 20, 30], "body": "Boss"}]}));
run(&mut s, "sketch.finish", json!({}));
assert_line(&s, 30.0, (10.0, 30.0), "drawn");
assert!(stored(&s).edge_name.is_some());
run(&mut s, "timeline.suppress", json!({"feature": "Combine1", "suppressed": false}));
assert!(s.model.state().body("Boss").is_none(), "the boss is part of the plate now");
assert_eq!(trouble(&s), "");
assert!(!link(&s).lost);
assert_line(&s, 30.0, (10.0, 30.0), "joined");
assert!(matches!(&link(&s).source, LinkSource::Edge { body, .. } if body == "Body1"));
}
/// A design saved before links kept the edge's name (format 16) has a link with a point only.
/// It opens and is found by that point, as it was, and takes the edge's name when its sketch is
/// next edited: undoing the edit takes the name away again, redoing it brings it back.
#[test]
fn an_edge_link_saved_without_a_name_is_found_as_before_and_named_by_the_next_edit() {
let mut s = Session::default();
plate(&mut s);
project_right_edge(&mut s, 25.0);
let name = edge_named_at(&mut s, [100.0, 25.0, 20.0]);
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
let links = saved["features"].as_array_mut().unwrap().iter_mut().filter_map(|f| f.pointer_mut("/sketch/links")?.as_array_mut());
let removed = links.flatten().filter_map(|l| l.as_object_mut()?.remove("edge_name")).count();
assert_eq!(removed, 1, "the one projected edge");
saved["format"] = json!("solidcraft/16");
let mut s = Session::new(solidcraft_doc::Document::from_json(&saved.to_string()).unwrap());
assert_eq!(s.doc.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT), "upgraded on opening");
assert_eq!(stored(&s).edge_name, None);
assert_eq!(trouble(&s), "");
assert_line(&s, 100.0, (0.0, 50.0), "opened");
// Found by its point: 0.2 further out the picked edge is still the nearest one.
set_width(&mut s, 100.2);
assert_eq!(stored(&s).edge_name, None);
assert_eq!(trouble(&s), "");
assert_line(&s, 100.2, (0.0, 50.0), "width 100.2");
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
run(&mut s, "edit.undo", json!({}));
assert_eq!(stored(&s).edge_name, None);
run(&mut s, "edit.redo", json!({}));
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
run(&mut s, "sketch.finish", json!({}));
set_width(&mut s, 150.0);
assert_eq!(trouble(&s), "");
assert_line(&s, 150.0, (0.0, 50.0), "width 150");
}
+5 -1
View File
@@ -130,6 +130,10 @@ 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>,
/// Persistent model edge identity of an `Edge` source, resolved by the document before
/// geometry refresh. None: the edge is the one nearest the source's point.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edge_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,
@@ -476,7 +480,7 @@ impl Sketch {
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.links.push(Link { id: id.clone(), kind, source, face_name: None, edge_name: None, auto_face: false, lost: false });
self.build_link(&id, &lay)?;
Ok(id)
}
+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, auto_face: false, lost: false });
self.links.push(Link { id: id.clone(), kind, source, face_name: None, edge_name: None, auto_face: false, lost: false });
id
} else {
self.add_link(kind, source, geom)?
+8
View File
@@ -80,6 +80,14 @@ longer lies on the face says so: `face ... was found by geometry, not exact hist
An edge name is found as the edge between the two faces it names, each found as above: when
exactly one edge runs between them it is that edge, even after a split of one of its faces.
A sketch's link to a projected edge (`sketch.project {edge: [x, y, z]}`, and Intersect and
Include 3D Geometry of an edge) keeps the edge's name beside its point. The link is that edge
wherever a change takes it; of an edge that was split it is the piece nearest the link's point,
and the sketch says so. When no edge has the name any more the link is lost and keeps its last
geometry, as it does for a face: it does not take whichever edge is nearest now. A link saved
without a name, by a build that kept only the point, is found by the edge nearest its point, as
it always was, and takes that edge's name when its sketch is next edited.
A hole placed by a position on a face is the exception to "the point nearest the reference's
point": its position is a place, not a pick. The hole follows its face along its own axis, so it
moves with the face when the face moves, and when the face no longer reaches the position it stays
+1 -1
View File
@@ -58,7 +58,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio
The default `cache: false` omits the portable thumbnail too.
The repository's `docs/design-container.md` describes limits and encoding details.
Current designs use format 17, including face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
Current designs use format 17, including face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
links, persistent
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
association profile references, durable sketch face anchors, periodic fit splines and versioned
+2
View File
@@ -59,6 +59,8 @@ design unchanged. Projected geometry remains protected from ordinary dragging.
**Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that
follows later changes. Sketching on a face projects its edges automatically.
A projected edge stays the edge that was picked, wherever a later change moves it; when that
edge no longer exists, the projection keeps its last shape and the sketch says it was lost.
For Project, select visible source curves or points from another sketch, model edges,
faces, vertices or bodies, or axes and planes, then press P. The entire supported selection
is projected in one undo step. A rejected projection keeps the selection for correction;