doc, engine: a stored name finds its very face or edge, or its feature asks to reselect it

A face or edge name a feature stored finds only the face or edge it names: the
very name (or a synonym of that face), or the named piece told apart by the
faces around it. A face split into several pieces, merged into another face,
or left only as a face of the same stem is not the face that was picked, and a
name found on several bodies is told apart only by the body the reference's
point lies on. The fallbacks are gone: no piece nearest the pick, no merged
face, no edge at the picked point, no lost link that keeps its last geometry.
A feature whose stored reference finds nothing fails with DocError::Reselect:
blends, face points, sketch planes, sketch projection links, construction
references, hole reference edges, sheet bend edges and cylinder emboss.

The tests that asserted a fallback now assert the request to reselect: split
and removed fillet edges, a merged or rejoined face, a projected edge that was
split or is gone, a projection whose body went, saved strip projections after
wall coalescence, a fillet saved on a piece of a tapered side that is whole
now, a cut that starts from a piece that is no longer that piece, and a fillet
named on the lower of two stacked boxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 12:17:38 -04:00
committed by jyviko
parent 4a652ba4a1
commit cfa6f38d6e
27 changed files with 395 additions and 452 deletions
+4 -9
View File
@@ -175,14 +175,9 @@ fn record_refs(out: &mut Vec<(String, bool)>, s: &Session, f: &Feature) {
("not checked (a cylindrical emboss finds its face on its own body)".to_string(), true)
} else {
match naming::locate_face_on_body(&fst, f.kind.face_body(), n, p) {
Some(hit) => (
format!(
"{}{}",
face_label(&fst, hit.body, hit.face),
flags(&[(hit.split, "split"), (hit.merged, "merged"), (!hit.verified, "geometric"), (hit.legacy, "legacy")])
),
true,
),
Some(hit) => {
(format!("{}{}", face_label(&fst, hit.body, hit.face), flags(&[(!hit.verified, "geometric"), (hit.legacy, "legacy")])), true)
}
None => ("gone".into(), false),
}
};
@@ -198,7 +193,7 @@ fn record_refs(out: &mut Vec<(String, bool)>, s: &Session, f: &Feature) {
"{} edge {}{}",
b.name,
naming::edge_names(b).get(hit.edge).map(String::as_str).unwrap_or("?"),
flags(&[(hit.split, "split"), (!hit.verified, "geometric"), (hit.legacy, "legacy")])
flags(&[(!hit.verified, "geometric"), (hit.legacy, "legacy")])
),
true,
),
+18 -32
View File
@@ -221,7 +221,7 @@ fn regularized_shell_keeps_legacy_and_explicit_single_face_projections_closed()
let mut sketch = solidcraft_sketch::Sketch::new();
let link = sketch.add_link(solidcraft_sketch::LinkKind::Project, source, &legacy.geom).unwrap();
sketch.links[0].face_name = Some(crate::naming::face_names(body)[index].clone());
let warnings = crate::project::refresh_links(&doc, &values, &state, &plane, &mut sketch);
let warnings = crate::project::refresh_links(&doc, &values, &state, &plane, &mut sketch).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert!(!sketch.links[0].lost);
assert!(!sketch.update_link(&link, &explicit.geom).unwrap());
@@ -308,7 +308,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
}
#[test]
fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
fn saved_curved_strip_projection_asks_for_its_face_again_after_exact_wall_coalescence() {
use solidcraft_sketch::{LinkKind, LinkSource, Sketch};
let mut doc = Document::new("saved curved strip");
doc.set_param("width", "80", Some("mm"), None).unwrap();
@@ -356,13 +356,13 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
// longest spread), from z = 0 to 2. Projected on YZ, the quarter round from (40, 24) to
// (34, 30) is the rectangle y 24..30, z 0..2: its two arcs and its two straight sides.
let former = format!("{stem}#1");
let reference = former.clone();
let source = LinkSource::Face { body: "Case".into(), at };
let (a, b, c, d) = (Vec2::new(24., 0.), Vec2::new(30., 0.), Vec2::new(30., 2.), Vec2::new(24., 2.));
let geometry: Vec<_> = [(a, b), (b, c), (c, d), (d, a)].into_iter().map(|(p, q)| solidcraft_sketch::LinkGeom::Line(p, q)).collect();
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, source, &geometry).unwrap();
sketch.links[0].face_name = Some(former);
let original = sketch.clone();
let sk = doc
.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane: solidcraft_geom::Plane::YZ }, sketch }, Some("Saved corner strip"))
.unwrap();
@@ -387,25 +387,21 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
let mut cold = Model::new();
cold.evaluate(&loaded);
for current in [&model, &cold] {
// No face is the piece any more, and the whole wall is not it: the sketch fails,
// asking for its face again, and so does the material made from it. The case builds.
let result = current.results.iter().find(|r| r.id == sk).unwrap();
assert!(result.error.is_none());
assert!(result.warning.as_deref().unwrap().contains("no longer exists; last geometry retained"));
assert_eq!(result.error.as_deref().and_then(crate::reselect_parts), Some(("Saved corner strip", reference.as_str())), "{result:?}");
assert!(current.results.iter().find(|r| r.id == boss).unwrap().error.is_some());
assert!(current.results.iter().filter(|r| r.id != sk && r.id != boss).all(|r| r.error.is_none()), "{:?}", current.results);
let state = current.state();
let solved = state.sketch(sk).unwrap();
assert!(solved.sketch.links[0].lost);
assert_eq!(solved.sketch.points, original.points);
assert_eq!(solved.sketch.curves, original.curves);
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
assert!(
(solidcraft_kernel::measure(&material.body).unwrap().volume - 12.).abs() < 1e-7,
"two-mm strip cannot expand to a full thirty-mm wall"
);
assert!(state.body("Case").is_some());
assert!(state.bodies.iter().all(|body| body.feature != boss), "two-mm strip cannot expand to a full thirty-mm wall");
}
}
}
#[test]
fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
fn saved_stepped_tab_cut_piece_projection_asks_for_its_face_again_without_expanding_material() {
use solidcraft_sketch::{LinkKind, LinkSource, Sketch};
let mut doc = Document::new("public stepped tab case");
doc.set_param("length", "40", Some("mm"), None).unwrap();
@@ -466,9 +462,8 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
let geometry: Vec<_> = (0..4).map(|i| solidcraft_sketch::LinkGeom::Line(corners[i], corners[(i + 1) % 4])).collect();
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &geometry).unwrap();
sketch.links[0].face_name = Some(former);
let original = sketch.clone();
let captured = original.points.iter().filter(|p| p.link.is_some()).map(|p| p.pos).collect::<Vec<_>>();
sketch.links[0].face_name = Some(former.clone());
let captured = sketch.points.iter().filter(|p| p.link.is_some()).map(|p| p.pos).collect::<Vec<_>>();
assert_eq!(captured.len(), 4);
assert!(captured.iter().all(|p| (p.x == 10. || p.x == 20.) && (p.y == 0. || p.y == 20.)));
let sk = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch }, Some("Saved wall patch")).unwrap();
@@ -493,22 +488,13 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
let mut cold = Model::new();
cold.evaluate(&reopened);
for current in [&model, &cold] {
// No face is the patch any more, and the whole wall is not it: the sketch fails,
// asking for its face again, and so does the stock made from it.
let result = current.results.iter().find(|r| r.id == sk).unwrap();
assert!(result.error.is_none(), "{result:?}");
assert!(result.warning.as_deref().is_some_and(|w| w.contains("no longer exists; last geometry retained")), "{result:?}");
assert_eq!(result.error.as_deref().and_then(crate::reselect_parts), Some(("Saved wall patch", former.as_str())), "{result:?}");
assert!(current.results.iter().find(|r| r.id == material).unwrap().error.is_some());
let state = current.state();
let saved = state.sketch(sk).unwrap();
assert!(saved.sketch.links[0].lost);
assert_eq!(saved.sketch.points, original.points);
assert_eq!(saved.sketch.curves, original.curves);
let stock = state.bodies.iter().find(|b| b.feature == material).unwrap();
assert!(
(solidcraft_kernel::measure(&stock.body).unwrap().volume - 200.).abs() < 1e-7,
"saved10x20 patch must not become30x20 wall; actualvolume={}, bounds={:?}, originalpoints={:?}",
solidcraft_kernel::measure(&stock.body).unwrap().volume,
stock.mesh().bounds(),
original.points
);
assert!(state.bodies.iter().all(|b| b.feature != material), "saved 10 x 20 patch must not become the 30 x 20 wall");
let case = state.body("Case").unwrap();
let expected = length * 30. * 20. + 8. * 10. * 8. - (length - 2. * wall) * (30. - 2. * wall) * (20. - wall);
assert!((solidcraft_kernel::measure(&case.body).unwrap().volume - expected).abs() < 1e-6);
+16 -16
View File
@@ -141,16 +141,16 @@ fn construct<'a>(st: &'a ModelState, name: &str) -> Result<&'a ConstructGeom> {
st.construct.iter().find(|c| c.name == name).map(|c| &c.geom).ok_or_else(|| DocError::Unknown(format!("construction `{name}`")))
}
/// The face picked: (body, face index, point on it).
/// The face picked: (body, face index, point on it). A face picked with a name is the face the
/// name finds, or the reference fails, asking for it to be picked again.
fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&'a ModelBody, usize, Vec3)> {
// The named face itself: its nearest point to the old pick can lie on an edge it shares
// (a corner round that a parameter moved away from the pick), where picking again by the
// point finds the flat face beside it (#1255).
if let Some(hit) = name.as_deref().and_then(|n| crate::name_lookup::locate_face(st, n, at))
&& let Some(b) = st.bodies.get(hit.body).filter(|b| !b.body.is_mesh())
&& hit.face < b.body.face_count()
{
return Ok((b, hit.face, hit.point));
if let Some(n) = name.as_deref() {
return crate::name_lookup::locate_face(st, n, at)
.and_then(|hit| st.bodies.get(hit.body).filter(|b| !b.body.is_mesh() && hit.face < b.body.face_count()).map(|b| (b, hit.face, hit.point)))
.ok_or_else(|| DocError::reselect(n));
}
for b in bodies(st) {
if let Some(fi) = crate::appearance::face_index_at(b, at) {
@@ -165,16 +165,13 @@ fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<V
edge_pick(st, at, name).map(|(_, _, pts)| pts)
}
/// The edge picked: its body, its index there (as `Body::edges` lists it) and its polyline.
/// The edge picked: its body, its index there (as `Body::edges` lists it) and its polyline. An
/// edge picked with a name is the edge the name finds, or the reference fails, asking for it to
/// be picked again.
fn edge_pick<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&'a ModelBody, usize, Vec<Vec3>)> {
let mut at = at;
if let Some(n) = name {
for b in bodies(st) {
if let Ok((q, _)) = crate::naming::find_edge(b, n, at) {
at = q;
break;
}
}
at = bodies(st).find_map(|b| crate::naming::find_edge(b, n, at).ok()).ok_or_else(|| DocError::reselect(n.as_str()))?;
}
let mut best: Option<(f64, &ModelBody, usize, Vec<Vec3>)> = None;
for b in bodies(st) {
@@ -216,7 +213,7 @@ fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
.iter()
.map(|n| {
// The edge with that name itself, never the one nearest the old pick.
let on = bodies(st).find_map(|b| crate::naming::find_edge(b, n, at).ok())?.0;
let on = bodies(st).find_map(|b| crate::naming::find_edge(b, n, at).ok())?;
let pts = edge_points(st, on, &None).ok()?;
Some([*pts.first()?, *pts.last()?])
})
@@ -683,12 +680,15 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
impl GeoRef {
/// Where a picked edge or face is now, found the way evaluation finds it (by its persistent
/// name, else at the picked point): a point on it. `None` for other references and for an
/// name when it has one, else at the picked point): a point on it. `None` for other references and for an
/// edge or face that is gone.
pub fn picked_point(&self, st: &ModelState) -> Option<Vec3> {
match self {
GeoRef::Edge { at, name } => {
let on = name.as_deref().and_then(|n| bodies(st).find_map(|b| crate::naming::find_edge(b, n, *at).ok())).map_or(*at, |(q, _)| q);
let on = match name.as_deref() {
Some(n) => bodies(st).find_map(|b| crate::naming::find_edge(b, n, *at).ok())?,
None => *at,
};
edge_pick(st, on, &None).ok().map(|_| on)
}
GeoRef::Face { at, name } => face_of(st, *at, name).ok().map(|(_, _, p)| p),
+67 -104
View File
@@ -3483,18 +3483,18 @@ fn trusted_edge_name(f: &Feature, i: usize, p: Vec3) -> Option<&str> {
}
/// The body (index) an edge name finds an edge on, when one does: the one where the edge still
/// runs through the pick `p`, else where the name is one edge (not split), else the nearest.
/// runs through the pick `p`, else the nearest.
fn named_body(st: &ModelState, name: &str, p: Vec3) -> Option<usize> {
// Names start with the feature that made the face: only bodies with such a face can hold it.
let bare = crate::naming::legacy_name(name).unwrap_or(name);
let token = bare.find(':').map(|k| &bare[..=k]);
let mut best: Option<(usize, (bool, bool, f64))> = None;
let mut best: Option<(usize, (bool, f64))> = None;
for (i, b) in st.bodies.iter().enumerate() {
if b.body.is_mesh() || token.is_some_and(|t| !crate::naming::face_names(b).iter().any(|n| n.contains(t))) {
continue;
}
let Ok(hit) = crate::naming::locate_edge(b, name, p) else { continue };
let score = (hit.on_point, !hit.split, -hit.point.dist(p));
let score = (hit.on_point, -hit.point.dist(p));
if best.as_ref().is_none_or(|(_, s)| score.partial_cmp(s).is_some_and(|o| o.is_gt())) {
best = Some((i, score));
}
@@ -3566,8 +3566,9 @@ fn blend_bodies(st: &ModelState, f: &Feature, body: &Option<crate::BodyName>, pt
Ok(out)
}
/// The edges `idx` of blend `f` found on body `mb`: by name first; a name that is gone falls back
/// to the point, with a warning.
/// The edges `idx` of blend `f` found on body `mb`: by name, where the edge was stored with one.
/// A name that no longer finds exactly its edge fails the blend, asking for the edge to be picked
/// again; so does a name a build stored for the wrong body (see `trusted_edge_name`).
fn blend_edges_on(mb: &ModelBody, f: &Feature, edges: &[crate::Position], idx: &[usize], warning: &mut Option<String>) -> Result<Vec<Vec3>> {
let pick = |i: usize| edges.get(i).map(|p| p.0).unwrap_or(Vec3::ZERO);
if !f.edge_names.iter().any(|n| !n.is_empty()) {
@@ -3578,51 +3579,34 @@ fn blend_edges_on(mb: &ModelBody, f: &Feature, edges: &[crate::Position], idx: &
let (mut out, mut notes) = (Vec::new(), Vec::new());
for &i in idx {
let p = pick(i);
let stored = f.edge_names.get(i).filter(|n| !n.is_empty());
let trusted = trusted_edge_name(f, i, p);
// A name recorded on another body (see `trusted_edge_name`): the pick alone.
if stored.is_some() && trusted.is_none() {
let Some(stored) = f.edge_names.get(i).filter(|n| !n.is_empty()) else {
// An edge picked without a name: by its point. A pick on the axis of a round must not
// fall through to the tangency seam (#775).
let mut w = None;
match resolve_edges(&mb.body, &[p], &[], &mut w) {
Ok(q) => out.extend(q),
Err(_) => notes.push("an edge reference was not found at its point and was skipped".to_string()),
let refs: Vec<EdgeRef> = f.edge_refs.get(i).cloned().into_iter().collect();
let skipped = "edge `?` no longer exists and was skipped";
if pick_lies_on_a_round_axis(&mb.body, p) {
notes.push(skipped.into());
} else if let Ok(q) = resolve_edges(&mb.body, &[p], &refs, &mut w) {
out.extend(q);
notes.push("an edge reference without a name was found by its point".into());
} else {
notes.push(skipped.into());
}
notes.extend(w);
continue;
};
let hit =
trusted_edge_name(f, i, p).and_then(|n| crate::naming::locate_edge(mb, n, p).ok()).ok_or_else(|| DocError::reselect(stored.as_str()))?;
if let Some(note) = hit.note(stored) {
notes.push(note);
}
match trusted.map(|n| (n, crate::naming::locate_edge(mb, n, p))) {
Some((n, Ok(hit))) => {
if let Some(note) = hit.note(n) {
notes.push(note);
}
// The body changed since the pick: the pick may now sit at an end of the edge.
let moved = f.edge_refs.get(i).is_some_and(|r| {
let now = mb.mesh().bounds();
let tight = (mb.body.size() * 2e-3).max(1e-3);
now.min.dist(r.min) > tight || now.max.dist(r.max) > tight
});
out.push(if moved { off_the_ends(mb, hit.edge, hit.point) } else { hit.point });
}
Some((_, Err(_))) | None => {
let mut w = None;
let refs: Vec<EdgeRef> = f.edge_refs.get(i).cloned().into_iter().collect();
let skipped = format!("edge `{}` no longer exists and was skipped", stored.map(String::as_str).unwrap_or("?"));
// The name is gone. A pick on the axis of a round must not fall through to the
// tangency seam (#775). A sharp edge that moved still resolves from the point,
// which an edit relies on.
if pick_lies_on_a_round_axis(&mb.body, p) {
notes.push(skipped);
} else if let Ok(q) = resolve_edges(&mb.body, &[p], &refs, &mut w) {
out.extend(q);
notes.push(match stored {
Some(n) => format!("edge `{n}` no longer exists; using the edge at the picked point"),
None => "an edge reference without a name was found by its point".into(),
});
} else {
notes.push(skipped);
}
}
}
// The body changed since the pick: the pick may now sit at an end of the edge.
let moved = f.edge_refs.get(i).is_some_and(|r| {
let now = mb.mesh().bounds();
let tight = (mb.body.size() * 2e-3).max(1e-3);
now.min.dist(r.min) > tight || now.max.dist(r.max) > tight
});
out.push(if moved { off_the_ends(mb, hit.edge, hit.point) } else { hit.point });
}
if out.is_empty() {
let why = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) };
@@ -5712,9 +5696,9 @@ fn copied_features(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelSta
/// `mats`' own (which keep the numbers they always had), each nested source in a block of its
/// own, so no two copies of one feature share an instance. Suppressed sources copy nothing.
/// `f` with each face point moved onto its named face (on the body the feature names, when it
/// names one and the face is there) in `state`, and notes for names that no longer find their
/// face. `None` when `f` names no face; an error when the frame the faces are looked up in
/// cannot be made (the feature fails with it).
/// names one and the face is there) in `state`, and notes on how the faces were found. `None`
/// when `f` names no face; an error when a name no longer finds its face (the feature asks for
/// it to be picked again), or when the frame the faces are looked up in cannot be made.
fn with_named_face_points(doc: &Document, state: &ModelState, f: &Feature) -> Result<Option<(Feature, Vec<String>)>> {
// Cylindrical emboss resolves its named face in the selected body's frame
// and certifies an analytic anchor; mesh projection here would undo that.
@@ -5754,7 +5738,7 @@ fn with_named_face_points(doc: &Document, state: &ModelState, f: &Feature) -> Re
_ => *p = hit.pick_point(&fst, *p),
}
}
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
None => return Err(DocError::reselect(n.as_str())),
}
}
Ok(Some((g, notes)))
@@ -6279,53 +6263,31 @@ pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u
})
}
/// The plane of the body face a sketch sits on, found again: among planar faces with the
/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it.
fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) -> (Plane, Option<String>) {
/// The plane of the body face a sketch sits on, found again: the face its stored `name` finds,
/// which must still be planar and face the picked plane's way (else the sketch fails, asking for
/// its face to be picked again); without a name, among planar faces with the picked plane's
/// normal, the one nearest `at`. The picked frame moves along its normal onto it.
fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) -> Result<(Plane, Option<String>)> {
let n = picked.normal();
// By name first: the face (or a piece of it) with that name, facing the same way.
if let Some(name) = name {
// (distance from `at`, plane offset along n, what to say about how it was found)
let mut found: Option<(f64, f64, Option<String>)> = None;
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
let named = crate::naming::faces_with_stem(b, name);
if named.is_empty() {
continue;
}
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(faces) = b.body.faces(tol) else { continue };
for f in faces {
let hit = named.contains(&f.index);
if let (true, Some(fnrm)) = (hit, f.plane_normal)
&& fnrm.dot(n) > 1.0 - 1e-6
{
let d = f.centroid.dist(at);
if found.as_ref().is_none_or(|x| d < x.0) {
// A name saved before format 14 is found as it was then, without a note.
let note = if crate::naming::legacy_name(name).is_some() {
None
} else if !crate::naming::face_has_name(b, f.index, name) {
Some(format!("face `{name}` was split; using the piece nearest the picked point"))
} else if !crate::naming::face_name_exact(b, f.index) && (f.centroid - at).dot(n).abs() > tol {
// Named from geometry and moved off the picked plane: geometry alone decided.
Some(format!("face `{name}` was found by geometry, not exact history; check it"))
} else {
None
};
found = Some((d, f.centroid.dot(n), note));
}
}
}
let hit = crate::naming::locate_face(st, name, at).ok_or_else(|| DocError::reselect(name))?;
let b = st.bodies.get(hit.body).ok_or_else(|| DocError::reselect(name))?;
let tol = (b.body.size() * 1e-3).max(1e-3);
let face = b.body.faces(tol)?.into_iter().find(|f| f.index == hit.face).ok_or_else(|| DocError::reselect(name))?;
if !face.plane_normal.is_some_and(|fnrm| fnrm.dot(n) > 1.0 - 1e-6) {
return Err(DocError::reselect(name));
}
if let Some((_, off, note)) = found {
return (picked.offset(off - picked.origin.dot(n)), note);
}
let (pl, w) = face_plane(st, picked, at, None);
let note = format!(
"the face `{name}` the sketch was on no longer exists; {}",
if w.is_some() { "the sketch stays where it was" } else { "using the face at the picked point" }
);
return (pl, Some(note));
// A name saved before format 14 is found as it was then, without a note.
let note = if crate::naming::legacy_name(name).is_none()
&& !crate::naming::face_name_exact(b, hit.face)
&& (face.centroid - at).dot(n).abs() > tol
{
// Named from geometry and moved off the picked plane: geometry alone decided.
Some(format!("face `{name}` was found by geometry, not exact history; check it"))
} else {
None
};
return Ok((picked.offset(face.centroid.dot(n) - picked.origin.dot(n)), note));
}
let mut best: Option<(f64, f64)> = None; // (distance from `at`, plane offset along n)
for b in &st.bodies {
@@ -6349,13 +6311,13 @@ fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) ->
}
}
}
match best {
Ok(match best {
Some((_, off)) => {
let shift = off - picked.origin.dot(n);
(picked.offset(shift), None)
}
None => (*picked, Some("the face the sketch was on is gone; the sketch stays where it was".into())),
}
})
}
/// A chamfer, equal or by its second distance or angle.
@@ -6449,7 +6411,7 @@ fn eval_feature(
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
}
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()),
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
@@ -6457,8 +6419,8 @@ fn eval_feature(
// ones its expressions give come first.
doc.apply_text_values(vals, &mut sk)?;
let mut warns = match anchor {
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor),
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk),
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
};
drop(fst);
sk.refresh_offsets()?;
@@ -8156,7 +8118,7 @@ mod named_face_lookup_tests {
assert!(b.mesh.get().is_none(), "{} was meshed", b.name);
}
let h = hit.unwrap();
assert_eq!((h.body, h.split, h.merged), (7, false, false));
assert_eq!(h.body, 7);
assert_eq!(found(hit), found(crate::naming::locate_face(&st, &name, top)));
// Looking over every body names (and so meshes) them all.
assert!(st.bodies.iter().all(|b| b.mesh.get().is_some()));
@@ -8177,12 +8139,12 @@ mod named_face_lookup_tests {
}
}
let moved = crate::name_lookup::locate_face_on_body(&st, Some("Part1"), &name, top).unwrap();
assert_eq!((moved.body, moved.split), (2, false));
assert_eq!(moved.body, 2);
}
/// A copy of a body has its source's face names. Over every body the name finds the face on
/// both, and the one nearest the point is taken as a piece of a split face. A feature on the
/// copy takes the copy's face, whole.
/// both, and takes the one the point lies on; a point on neither finds no face (it is never
/// told apart by the nearest). A feature on the copy takes the copy's face.
#[test]
fn the_same_face_on_a_copy_is_not_a_piece_of_the_named_bodys_face() {
let mut st = parts(2);
@@ -8194,7 +8156,8 @@ mod named_face_lookup_tests {
let on_copy = crate::name_lookup::locate_face_on_body(&st, Some("Part1"), &name, top).unwrap();
let everywhere = crate::naming::locate_face(&st, &name, top).unwrap();
assert_eq!((on_copy.body, on_copy.face, on_copy.point), (everywhere.body, everywhere.face, everywhere.point));
assert_eq!((on_copy.split, everywhere.split), (false, true));
assert_eq!(on_copy.note(&name), None);
assert!(crate::naming::locate_face(&st, &name, Vec3::new(15.0, 5.0, 30.0)).is_none());
assert_eq!(crate::name_lookup::locate_face_on_body(&st, Some("Part1"), &name, Vec3::new(15.0, 5.0, 30.0)).map(|hit| hit.body), Some(1));
}
}
+7 -18
View File
@@ -44,17 +44,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
{
let mut scoped = ModelState::default();
scoped.bodies.push(mb.clone());
let (picked, split) = crate::naming::point_on_face(&scoped, name, at)
.ok_or_else(|| DocError::Invalid("selected cylinder face no longer exists".into()))?;
if split {
return Err(DocError::Invalid("selected cylinder face was split".into()));
}
let (index, _, _) = crate::project::nearest_face(&mb.mesh(), picked)
.ok_or_else(|| DocError::Invalid("selected cylinder face cannot be resolved".into()))?;
if !crate::naming::face_has_name(&mb, index as usize, name) {
return Err(DocError::Invalid("selected cylinder face is ambiguous".into()));
}
at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?;
let hit = crate::name_lookup::locate_face(&scoped, name, at).ok_or_else(|| DocError::reselect(name.as_str()))?;
at = kernel::cylinder_emboss_anchor(&mb.body, hit.face, hit.point)?;
}
let result = kernel::emboss_cylinder_at(&mb.body, at, &plane, &regions, if *deboss { -d } else { d })?;
if let Some(slot) = st.bodies.get_mut(i) {
@@ -759,21 +750,19 @@ pub(super) fn sheet_eval(
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let r = doc.sheet_rule(rule.as_deref());
let values = |t: Option<f64>| doc.rule_values_at(vals, &r, t);
// The bend edges by name first; a name that is gone keeps its point, with a warning.
// The bend edges by name where they have one: a name that no longer finds its edge
// fails the conversion, asking for the edge to be picked again.
let mut picks = Vec::with_capacity(bends.len());
for (i, p) in bends.iter().enumerate() {
let name = f.edge_names.get(i).filter(|n| !n.is_empty());
picks.push(match name.map(|n| (n, crate::naming::locate_edge(&mb, n, p.0))) {
Some((n, Ok(hit))) => {
picks.push(match name {
Some(n) => {
let hit = crate::naming::locate_edge(&mb, n, p.0).map_err(|_| DocError::reselect(n.as_str()))?;
if let Some(note) = hit.note(n) {
append_warning(warning, note);
}
hit.point
}
Some((n, Err(_))) => {
append_warning(warning, format!("edge `{n}` no longer exists; using the edge at the picked point"));
p.0
}
None => p.0,
});
}
+6 -5
View File
@@ -12,9 +12,10 @@
//! piece of `F2:top` bounded by the block's left wall and the slot's left wall. Pieces nothing
//! around them tells apart are numbered (`#1`, `#2`): in the order of what they came from when
//! that differs (exact), else in the kernel's order (unverified). A face made of several (a
//! coplanar join) takes one name and keeps the others as aliases, the faces merged into it. A
//! face a feature built from several of its own roles (a side drawn in collinear pieces) has each
//! of them as a name (synonyms): each names that very face.
//! coplanar join) takes one name and keeps the others as aliases: a reference to one of them is
//! to a face that is no longer as it was picked, and finds nothing. A face a feature built from
//! several of its own roles (a side drawn in collinear pieces) has each of them as a name
//! (synonyms): each names that very face.
//!
//! A piece that is split again keeps the face's stem and the bounds that told the piece it
//! came from apart: its name lists the faces around it, not the qualifiers of the pieces
@@ -40,8 +41,8 @@ pub struct Names {
/// What a face answers to besides its name, and how sure its name is.
#[derive(Clone, Debug, Default)]
pub(crate) struct FaceInfo {
/// Names of the other faces merged into it (a reference to one of them finds this face, as
/// the face it was merged into).
/// Names of the other faces merged into it. A reference to one of them is to a face that is
/// not this one as it was picked: a feature's reference by one finds nothing (`name_lookup`).
pub aliases: Vec<String>,
/// Other names of this very face: the roles besides its first of a face the feature that
/// built it made from several (a side drawn in collinear pieces is one face, made by each).
+8 -4
View File
@@ -167,12 +167,16 @@ fn to_polyline(p: Vec3, e: &[Vec3]) -> f64 {
e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min)
}
/// Reference edge `k` (its ends, and its index in the mesh), found by `name`, else by the edge
/// nearest `p`: one that runs through `p` for a fresh pick, within a quarter of the body for a
/// stored one. It must be straight.
/// Reference edge `k` (its ends, and its index in the mesh), found by `name` (a name that finds
/// no edge fails, asking for the edge to be picked again), else by the edge nearest `p`: one
/// that runs through `p` for a fresh pick, within a quarter of the body for a stored one. It
/// must be straight.
fn find_edge(b: &ModelBody, m: &Mesh, k: usize, p: Vec3, name: Option<&str>, exact: bool) -> Result<(Vec3, Vec3, usize)> {
let size = b.body.size().max(1e-9);
let by_name = name.filter(|n| !n.is_empty()).and_then(|n| crate::naming::locate_edge(b, n, p).ok()).map(|hit| hit.edge);
let by_name = match name.filter(|n| !n.is_empty()) {
Some(n) => Some(crate::naming::locate_edge(b, n, p).map_err(|_| DocError::reselect(n))?.edge),
None => None,
};
let i = match by_name {
Some(i) => i,
None => {
+1 -1
View File
@@ -341,7 +341,7 @@ fn profile_points(doc: &Document, st: &ModelState, p: &CamProfile, cam: &super::
let (body, p) = bodies
.iter()
.enumerate()
.find_map(|(i, b)| crate::naming::find_edge(b, name, *pick).ok().map(|(p, _)| (i, p)))
.find_map(|(i, b)| crate::naming::find_edge(b, name, *pick).ok().map(|p| (i, p)))
.ok_or_else(|| bad(&format!("the edge `{name}` no longer exists (edit the cam to pick again)")))?;
(on, at) = (Some(body), p);
}
+2 -2
View File
@@ -54,7 +54,7 @@ fn face_of(b: &ModelBody, i: usize, name: Option<&str>, pick: Vec3) -> Result<(u
let planar = |f: usize| infos.iter().find(|x| x.index == f).and_then(|x| x.plane_normal.map(|n| (x.index, n, x.centroid, x.area)));
let chosen = match name {
Some(n) => {
let (f, _, _) = crate::naming::face_on_body(b, n, pick)
let (f, _) = crate::naming::face_on_body(b, n, pick)
.ok_or_else(|| DocError::Invalid(format!("joint origin: the face `{n}` no longer exists (edit the joint to pick again)")))?;
if !beside.contains(&f) {
return Err(DocError::Invalid(format!("joint origin: the face `{n}` no longer meets its edge (edit the joint to pick again)")));
@@ -190,7 +190,7 @@ pub fn edge_snap(doc: &Document, st: &ModelState, comp: u64, pick: Vec3, on: Opt
Some(p) => {
let f = crate::naming::face_name_at_body(b, p)
.and_then(|n| crate::naming::face_on_body(b, &n, p))
.map(|(f, _, _)| f)
.map(|(f, _)| f)
.ok_or_else(|| DocError::Invalid("joint origin: `on` is not on a face of that body".into()))?;
if !beside.contains(&f) {
return Err(DocError::Invalid("joint origin: `on` picks a face the edge doesn't bound".into()));
+5 -6
View File
@@ -32,17 +32,16 @@ fn gone(name: &str) -> DocError {
/// The face called `name` on the bodies of `component` nearest `pick` (an error when it is gone:
/// a joint must not quietly attach elsewhere). Each body is looked at in its own coordinates, and
/// a face the name finds exactly comes before a piece of it or a face it was merged into. Only the
/// component's own bodies: the parts of one STEP import share their face names, and another
/// the nearest of the faces the name finds is taken. Only the component's own bodies: the parts of one STEP import share their face names, and another
/// part's face of that name can lie nearer the pick than this part's own.
pub(super) fn named_face<'a>(doc: &Document, st: &'a ModelState, component: u64, pick: Vec3, name: &str) -> Result<NamedFace<'a>> {
let own = component_bodies(doc, st, component).into_iter().filter_map(|body| {
let offset = doc.model_body_offset(body).copied().unwrap_or(IDENTITY);
let (face, at, inexact) = crate::naming::face_on_body(body, name, apply_point(&mat_inverse(&offset)?, pick))?;
let (face, at) = crate::naming::face_on_body(body, name, apply_point(&mat_inverse(&offset)?, pick))?;
let point = apply_point(&offset, at);
Some(((inexact, point.dist(pick)), NamedFace { body, face, point, offset }))
Some((point.dist(pick), NamedFace { body, face, point, offset }))
});
if let Some((_, nf)) = own.min_by(|(a, _), (b, _)| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))) {
if let Some((_, nf)) = own.min_by(|(a, _), (b, _)| a.total_cmp(b)) {
return Ok(nf);
}
// On none of them (the face's body has gone to another component since): wherever the name
@@ -53,7 +52,7 @@ pub(super) fn named_face<'a>(doc: &Document, st: &'a ModelState, component: u64,
return Ok(NamedFace { body, face: hit.face, point: hit.point, offset: IDENTITY });
};
let inv = mat_inverse(&offset).ok_or_else(|| gone(name))?;
let (face, at, _) = crate::naming::face_on_body(body, name, apply_point(&inv, pick)).ok_or_else(|| gone(name))?;
let (face, at) = crate::naming::face_on_body(body, name, apply_point(&inv, pick)).ok_or_else(|| gone(name))?;
Ok(NamedFace { body, face, point: apply_point(&offset, at), offset })
}
+117 -129
View File
@@ -1,12 +1,15 @@
//! Finding a face or edge again by the name a feature stored for it.
//!
//! The face with that name is the face; else the one face answering to it (a face merged into
//! it, or the named piece told apart by the faces around it, which still bound it); else, when
//! several faces are pieces of the named one, the piece nearest the reference's point, and the
//! feature says the face was split. Names saved before format 14 ([`LEGACY_MARK`]) are found
//! among the names geometry gives, as the builds that saved them found them. A face whose name
//! was worked out from geometry, found where the reference's point no longer lies, is reported:
//! only geometry decided.
//! The face with that name is the face (a face its feature made from several of its roles, a
//! side drawn in collinear pieces, has each as a name); else the one face answering to it as
//! exactly: the named piece told apart by the faces around it, which still bound it. Nothing
//! else is: when the named face was split into several pieces, when it was merged into another
//! face, when only a face of the same stem is left, or when the name is on several bodies, the
//! name finds nothing and the feature that uses it fails, asking for the reference to be picked
//! again ([`crate::DocError::Reselect`]). Names saved before format 14 ([`LEGACY_MARK`]) are
//! found among the names geometry gives, as the builds that saved them found them. A face whose
//! name was worked out from geometry, found where the reference's point no longer lies, is
//! reported: only geometry decided.
use solidcraft_geom::{Mesh, Vec3};
@@ -34,8 +37,6 @@ pub struct EdgeHit {
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.
pub split: bool,
/// Whether the names of the faces it separates follow exact history (and the edge is not
/// one of several between them, numbered by where they lie).
pub verified: bool,
@@ -46,8 +47,8 @@ pub struct EdgeHit {
}
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
/// between the faces it names (followed through splits and merges of those faces); else the
/// piece of it nearest `near` (`split`). `Err` explains when there is none.
/// between the faces it names exactly (see [`exactly`]). `Err` explains when there is none: no
/// such edge, or the named edge split into several.
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
if let Some(old) = legacy_name(name) {
return legacy_locate_edge(b, old, near).map_err(|_| format!("edge `{name}` no longer exists"));
@@ -64,7 +65,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 { edge: i, 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 }, verified: verified(i), on_point: on, legacy: false })
.ok_or_else(|| "edge".into())
};
if let Some(i) = names.iter().position(|n| n == name) {
@@ -75,19 +76,22 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
let mut best: Option<(Match, Vec<usize>)> = None;
for (i, fs) in m.edge_faces.iter().enumerate() {
let [fa, fb] = fs.as_slice() else { continue };
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted) else { continue };
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted).filter(|level| exactly(*level)) else { continue };
keep_best(&mut best, level, i);
}
let (level, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
if let ([i], true) = (cands.as_slice(), level >= Match::Piece) {
return exact(*i);
let (_, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
match cands.as_slice() {
[i] => exact(*i),
_ => Err(format!("edge `{name}` is no longer one edge")),
}
cands
.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 { edge: i, point: p, split: true, verified: verified(i), on_point: false, legacy: false })
.ok_or_else(|| format!("edge `{name}` no longer exists"))
}
/// Whether a face found at `level` is exactly the face a name named: the very name (or another
/// name of that very face: a face its feature made from several roles has each), or the named
/// piece told apart by the faces around it. A face the named one was merged into, or a face of
/// the same stem, is not.
fn exactly(level: Match) -> bool {
matches!(level, Match::Exact | Match::Piece)
}
/// Whether the picked point `near` lies on edge `i` of body `b` (whose mesh is `m`): then a
@@ -138,16 +142,14 @@ fn legacy_locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit,
let names = legacy_edge_names(b);
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 { edge: i, point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
.ok_or(());
return edge_mid(&m, i).map(|p| EdgeHit { edge: i, point: if on { near } else { p }, 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).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 })
.map(|(p, i)| EdgeHit { edge: i, point: p, verified: false, on_point: false, legacy: true })
.ok_or(())
}
@@ -185,11 +187,6 @@ pub struct FaceHit {
pub face: usize,
/// The point on the face nearest the reference's point.
pub point: Vec3,
/// The named face is not one face any more (split, or found on several bodies): the piece
/// nearest the point was taken.
pub split: bool,
/// The named face was merged into this one.
pub merged: bool,
/// Whether the face's name follows exact history.
pub verified: bool,
/// Whether the reference's point still lies on the face (so a name worked out from geometry
@@ -199,14 +196,21 @@ pub struct FaceHit {
pub legacy: bool,
}
/// Why a stored face name finds no face.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Miss {
/// No face has the name, or only a face it was merged into or a face of the same stem is
/// left.
Gone,
/// Several faces answer to it as well: the pieces of a split face, or the same face on
/// several bodies.
Several,
}
impl FaceHit {
/// What a feature says about how it found a face by its stored name `name` (`None`: exactly).
pub fn note(&self, name: &str) -> Option<String> {
if self.split {
Some(format!("face `{name}` was split; using the piece nearest the picked point"))
} else if self.merged {
Some(format!("face `{name}` was merged into another face; using that face"))
} else if !self.verified && !self.on_point && !self.legacy {
if !self.verified && !self.on_point && !self.legacy {
Some(format!("face `{name}` was found by geometry, not exact history; check it"))
} else {
None
@@ -316,9 +320,7 @@ fn inside_nearest(m: &Mesh, face: usize, p: Vec3, margin: f64) -> Option<Vec3> {
impl EdgeHit {
/// What a feature says about how it found an edge by its stored name `name` (`None`: exactly).
pub fn note(&self, name: &str) -> Option<String> {
if self.split {
Some(format!("edge `{name}` was split; using the piece nearest the picked point"))
} else if !self.verified && !self.on_point && !self.legacy {
if !self.verified && !self.on_point && !self.legacy {
Some(format!("edge `{name}` was found by geometry, not exact history; check it"))
} else {
None
@@ -326,103 +328,100 @@ impl EdgeHit {
}
}
/// The face a stored face name finds, on any body: the face with that name, or the one face
/// answering to it (merged into it, or the named piece told apart by the faces around it); else
/// the piece of it nearest `p` (`split`).
/// The face a stored face name finds, on any body: the face with that name, or the named piece
/// told apart by the faces around it. `None` when no face does, or several do.
pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
if crate::sheet::split_sheet_face_name(name).is_some() {
return sheet_locate(st, name, p);
return sheet_locate(st, name, p).ok();
}
// Every body's names are read below: work out the ones not known yet on all cores first
// (each body's names are its own, whichever thread works them out).
if legacy_name(name).is_none() {
naming::warm_names(st.bodies.iter().filter(|b| !b.body.is_mesh()));
}
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p)
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p).ok()
}
/// [`locate_face`] for a feature that works on the faces of the body called `body` (a shell, a
/// draft…): the one face of that body with the very name, found without working out the names
/// of every other body (a body named from geometry is meshed for it: seconds in a large model).
/// Else as [`locate_face`], over every body: the name moved to another body (a combine took the
/// face), or on this body it names a piece of a split face or a merged one. The same face on
/// another body (a copy of this one) is not a piece of this body's face.
/// draft…): the face of that body the name finds, found without working out the names of every
/// other body (a body named from geometry is meshed for it: seconds in a large model). When
/// that body has no such face, as [`locate_face`] over every body: the name moved to another
/// body (a combine took the face). The same face on another body (a copy of this one) does not
/// make this body's face ambiguous.
pub fn locate_face_on_body(st: &ModelState, body: Option<&str>, name: &str, p: Vec3) -> Option<FaceHit> {
if crate::sheet::split_sheet_face_name(name).is_some() {
return sheet_locate(st, name, p);
if crate::sheet::split_sheet_face_name(name).is_some() || legacy_name(name).is_some() {
return locate_face(st, name, p);
}
if legacy_name(name).is_none()
&& let Some(i) = body.and_then(|n| st.bodies.iter().position(|b| b.name == n))
if let Some(i) = body.and_then(|n| st.bodies.iter().position(|b| b.name == n))
&& let Some(b) = st.bodies.get(i).filter(|b| !b.body.is_mesh())
&& let Some(hit) = locate_in(std::iter::once((i, b)), name, p)
&& !hit.split
&& naming::names(b).faces.get(hit.face).is_some_and(|n| n == name)
{
return Some(hit);
match locate_in(std::iter::once((i, b)), name, p) {
Ok(hit) => return Some(hit),
Err(Miss::Several) => return None,
Err(Miss::Gone) => {}
}
}
locate_face(st, name, p)
}
/// [`locate_face`] for a sheet face's readable name (`sheet:<body>:<panel>:<side>`, see
/// [`crate::sheet::body_sheet_face_names`]): the face of that body the sheet names so, else the
/// piece of it nearest `p` (`split`). The sheet itself says which face it is, so the name holds
/// however the faces of the body are numbered.
fn sheet_locate(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
let (body, _) = crate::sheet::split_sheet_face_name(name)?;
let (bi, b) = st.bodies.iter().enumerate().find(|(_, b)| b.name == body && !b.body.is_mesh())?;
/// [`crate::sheet::body_sheet_face_names`]): the face of that body the sheet names so. The sheet
/// itself says which face it is, so the name holds however the faces of the body are numbered.
fn sheet_locate(st: &ModelState, name: &str, p: Vec3) -> Result<FaceHit, Miss> {
let (body, _) = crate::sheet::split_sheet_face_name(name).ok_or(Miss::Gone)?;
let (bi, b) = st.bodies.iter().enumerate().find(|(_, b)| b.name == body && !b.body.is_mesh()).ok_or(Miss::Gone)?;
let names = crate::sheet::body_sheet_face_names(st, b);
let faces: Vec<usize> = names.iter().enumerate().filter(|(_, n)| n.as_deref() == Some(name)).map(|(i, _)| i).collect();
let m = b.mesh();
let (distance, face, point) =
faces.iter().filter_map(|f| nearest_on_face(&m, *f, p).map(|(d, q)| (d, *f, q))).min_by(|a, c| a.0.total_cmp(&c.0))?;
Some(FaceHit {
body: bi,
face,
point,
split: faces.len() > 1,
merged: false,
verified: true,
on_point: distance <= (b.body.size() * 1e-3).max(1e-3),
legacy: false,
})
let face = match faces.as_slice() {
[face] => *face,
[] => return Err(Miss::Gone),
_ => return Err(Miss::Several),
};
let (distance, point) = nearest_on_face(&b.mesh(), face, p).ok_or(Miss::Gone)?;
Ok(FaceHit { body: bi, face, point, verified: true, on_point: distance <= (b.body.size() * 1e-3).max(1e-3), legacy: false })
}
/// [`locate_face`] among `bodies` (with their indices).
fn locate_in<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &str, p: Vec3) -> Option<FaceHit> {
/// [`locate_face`] among `bodies` (with their indices): the one face the name finds there.
fn locate_in<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &str, p: Vec3) -> Result<FaceHit, Miss> {
if let Some(old) = legacy_name(name) {
return legacy_locate(bodies, old, p);
return legacy_locate(bodies, old, p).ok_or(Miss::Gone);
}
let mut best: Option<(Match, Vec<(usize, &ModelBody, usize)>)> = None;
for (bi, b) in bodies {
let names = naming::names(b);
for face in 0..names.faces.len() {
if let Some(level) = crate::history_names::matches(&names, face, name) {
if let Some(level) = crate::history_names::matches(&names, face, name).filter(|level| exactly(*level)) {
keep_best(&mut best, level, (bi, b, face));
}
}
}
let (level, faces) = best?;
let unique = faces.len() == 1 && level >= Match::Piece;
let mut nearest: Option<(f64, usize, &ModelBody, usize, Vec3)> = None;
for (bi, b, face) in faces {
if let Some((d, q)) = nearest_on_face(&b.mesh(), face, p)
&& nearest.as_ref().is_none_or(|n| d < n.0)
{
nearest = Some((d, bi, b, face, q));
}
let (level, faces) = best.ok_or(Miss::Gone)?;
let found = |(body, b, face): (usize, &ModelBody, usize)| {
let (distance, point) = nearest_on_face(&b.mesh(), face, p)?;
Some(FaceHit {
body,
face,
point,
verified: naming::names(b).verified(face),
on_point: distance <= (b.body.size() * 1e-3).max(1e-3),
legacy: false,
})
};
if let [one] = faces.as_slice() {
return found(*one).ok_or(Miss::Gone);
}
// The very face on several bodies (a body and its copies, whose faces keep their names): the
// one the reference's point lies on. Pieces of one face are never told apart by a point.
let mut bodies: Vec<usize> = faces.iter().map(|(bi, _, _)| *bi).collect();
bodies.dedup();
if level != Match::Exact || bodies.len() < faces.len() {
return Err(Miss::Several);
}
let mut on = faces.into_iter().filter_map(found).filter(|hit| hit.on_point);
match (on.next(), on.next()) {
(Some(hit), None) => Ok(hit),
_ => Err(Miss::Several),
}
let (distance, body, b, face, point) = nearest?;
let on_point = distance <= (b.body.size() * 1e-3).max(1e-3);
Some(FaceHit {
body,
face,
point,
split: !unique,
merged: level == Match::Alias,
verified: naming::names(b).verified(face),
on_point,
legacy: false,
})
}
/// [`locate_face`] for a name saved before format 14 (without its mark), as those builds found
@@ -430,7 +429,7 @@ fn locate_in<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &st
/// nearer `p`), the nearest point on it.
fn legacy_locate<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &str, p: Vec3) -> Option<FaceHit> {
let stem = strip_piece(name);
let mut best: Option<(f64, usize, usize, Vec3, bool, f64)> = None;
let mut best: Option<(f64, usize, usize, Vec3, f64)> = None;
for (bi, b) in bodies {
let names = legacy_face_names(b);
let tol = (b.body.size() * 1e-3).max(1e-3);
@@ -445,28 +444,19 @@ fn legacy_locate<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name:
let q = closest(p, a, bb, c);
let d = q.dist(p) - if exact { 1e-9 } else { 0.0 };
if d.is_finite() && best.is_none_or(|x| d < x.0) {
best = Some((d, bi, *f as usize, q, !exact, tol));
best = Some((d, bi, *f as usize, q, tol));
}
}
}
best.map(|(d, body, face, point, split, tol)| FaceHit {
body,
face,
point,
split,
merged: false,
verified: false,
on_point: d <= tol,
legacy: true,
})
best.map(|(d, body, face, point, tol)| FaceHit { body, face, point, verified: false, on_point: d <= tol, legacy: true })
}
/// Whether face `index` of a body is the face a stored name names: that name (or one merged into
/// it); for a name saved before format 14, that name among the names those builds gave.
/// Whether face `index` of a body is exactly the face a stored name names (see [`exactly`]); for
/// a name saved before format 14, that name among the names those builds gave.
pub fn face_has_name(b: &ModelBody, index: usize, name: &str) -> bool {
match legacy_name(name) {
Some(old) => legacy_face_names(b).get(index).is_some_and(|n| n == old),
None => crate::history_names::matches(&naming::names(b), index, name).is_some_and(|m| m >= Match::Alias),
None => crate::history_names::matches(&naming::names(b), index, name).is_some_and(exactly),
}
}
@@ -487,7 +477,7 @@ pub fn faces_with_stem(b: &ModelBody, name: &str) -> Vec<usize> {
/// [`locate_face`] on one body.
pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit> {
locate_in(std::iter::once((0, body)), name, p)
locate_in(std::iter::once((0, body)), name, p).ok()
}
/// The one face a name `model.faces` lists finds, on the body called `body` or, without one, on
@@ -504,11 +494,10 @@ pub fn locate_one(st: &ModelState, body: Option<&str>, name: &str) -> Result<Fac
if body.is_some_and(|n| n != sheet_body) {
return Err(format!("`{name}` is a face of `{sheet_body}`"));
}
let hit = sheet_locate(st, name, Vec3::ZERO).ok_or_else(|| format!("no face is named `{name}` (`model.faces` lists the names)"))?;
if hit.split {
return Err(format!("`{name}` names more than one face (pieces of a split face): give the name of one piece"));
}
return Ok(hit);
return sheet_locate(st, name, Vec3::ZERO).map_err(|miss| match miss {
Miss::Gone => format!("no face is named `{name}` (`model.faces` lists the names)"),
Miss::Several => format!("`{name}` names more than one face (pieces of a split face): give the name of one piece"),
});
}
let bodies: Vec<(usize, &ModelBody)> =
st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh() && body.is_none_or(|n| b.name == n)).collect();
@@ -516,11 +505,10 @@ pub fn locate_one(st: &ModelState, body: Option<&str>, name: &str) -> Result<Fac
if bodies.len() > 1 && legacy_name(name).is_none() {
naming::warm_names(bodies.iter().map(|(_, b)| *b));
}
let hit = locate_in(bodies.into_iter(), name, Vec3::ZERO).ok_or_else(|| format!("no face is named `{name}` (`model.faces` lists the names)"))?;
if hit.split {
return Err(format!(
"`{name}` names more than one face (pieces of a split face, or faces of several bodies): give `body`, or the name of one piece"
));
}
Ok(hit)
locate_in(bodies.into_iter(), name, Vec3::ZERO).map_err(|miss| match miss {
Miss::Gone => format!("no face is named `{name}` (`model.faces` lists the names)"),
Miss::Several => {
format!("`{name}` names more than one face (pieces of a split face, or faces of several bodies): give `body`, or the name of one piece")
}
})
}
+4 -4
View File
@@ -28,7 +28,7 @@ fn named_manual_projection_keeps_its_body_and_face_at_shared_boundaries() {
sketch.links[0].source = LinkSource::Face { body: "Source".into(), at: Vec3::new(0., 0., 10.) };
let mut current = ModelState::default();
current.bodies.extend([named_box("Source", 20., 20.), named_box("Control", 5., 10.)]);
assert!(crate::project::refresh_links(&doc, &BTreeMap::new(), &current, &plane, &mut sketch).is_empty());
assert!(crate::project::refresh_links(&doc, &BTreeMap::new(), &current, &plane, &mut sketch).unwrap().is_empty());
assert!(!sketch.links[0].lost);
assert!(sketch.points.iter().any(|point| point.pos.x > 19.));
assert!(sketch.points.iter().any(|point| point.pos.y > 19.));
@@ -40,9 +40,9 @@ fn named_manual_projection_keeps_its_body_and_face_at_shared_boundaries() {
let saved = sketch.clone();
let mut missing = ModelState::default();
missing.bodies.push(named_box("Control", 5., 10.));
let warnings = crate::project::refresh_links(&doc, &BTreeMap::new(), &missing, &plane, &mut sketch);
assert!(sketch.links[0].lost);
assert!(!warnings.is_empty());
// Its body gone, the named face finds nothing: the sketch fails, asking for it again.
let error = crate::project::refresh_links(&doc, &BTreeMap::new(), &missing, &plane, &mut sketch).unwrap_err();
assert!(matches!(&error, crate::DocError::Reselect { reference, .. } if reference == "shared:top"), "{error}");
assert_eq!(sketch.curves, saved.curves);
assert_eq!(sketch.points, saved.points);
assert_eq!(sketch.links[0].id, link);
+11 -12
View File
@@ -939,10 +939,10 @@ fn edge_names_from(b: &ModelBody, faces: &[String]) -> Vec<String> {
names
}
/// The edge of a body with this name: exact, else the pieces of the same edge nearest `near`
/// (`Err` explains when there is none).
pub fn find_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<(Vec3, bool), String> {
locate_edge(b, name, near).map(|hit| (hit.point, hit.split))
/// A point on the edge of a body this name finds (see [`locate_edge`]; `Err` explains when there
/// is none).
pub fn find_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<Vec3, String> {
locate_edge(b, name, near).map(|hit| hit.point)
}
/// Names of the edges nearest each point on a body (for storing references).
@@ -1100,21 +1100,20 @@ pub fn named_face_point(st: &ModelState, body: Option<&str>, name: &str) -> Resu
face_spot(b, hit.face).map(|spot| spot.point).ok_or_else(|| format!("face `{name}` has no surface to pick"))
}
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
/// `true` for "not exactly that face": split, or merged into another); `None` when no face has
/// the name.
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
locate_face(st, name, p).map(|hit| (hit.point, hit.split || hit.merged))
/// The point on the face this name finds nearest `p`; `None` when the name finds no face (see
/// [`locate_face`]).
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<Vec3> {
locate_face(st, name, p).map(|hit| hit.point)
}
/// Resolve a legacy named face only on its recorded source body. Return the
/// selected face index as well as the closest point, which may lie on a shared
/// edge and therefore must not be used to pick the face a second time.
pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec3, bool)> {
pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec3)> {
if body.body.is_mesh() {
return None;
}
crate::name_lookup::locate_on(body, name, p).map(|hit| (hit.face, hit.point, hit.split || hit.merged))
crate::name_lookup::locate_on(body, name, p).map(|hit| (hit.face, hit.point))
}
#[cfg(test)]
@@ -1140,7 +1139,7 @@ mod tests {
fn same_hit(a: &FaceHit, b: &FaceHit) {
assert_eq!((a.body, a.face, a.point), (b.body, b.face, b.point));
assert_eq!((a.split, a.merged, a.verified, a.on_point, a.legacy), (b.split, b.merged, b.verified, b.on_point, b.legacy));
assert_eq!((a.verified, a.on_point, a.legacy), (b.verified, b.on_point, b.legacy));
}
#[test]
+26 -44
View File
@@ -600,20 +600,24 @@ pub fn resolved_edge_name(st: &ModelState, r: &Resolved) -> Option<String> {
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
/// links; lost links keep their last geometry.
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.
/// body has that name any more (it was joined into another), the one body on which the name
/// finds an edge.
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))),
None => {
let mut found = st.bodies.iter().filter(|b| !b.body.is_mesh()).filter_map(hit);
match (found.next(), found.next()) {
(Some(one), None) => Some(one),
_ => None,
}
}
}
}
@@ -752,6 +756,7 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
})
}
/// [`refresh_links`] for a sketch attached to a face by `anchor`.
pub fn refresh_face_anchor_links(
doc: &Document,
vals: &BTreeMap<String, Value>,
@@ -759,7 +764,7 @@ pub fn refresh_face_anchor_links(
plane: &Plane,
sk: &mut Sketch,
anchor: &crate::FaceAnchor,
) -> Vec<String> {
) -> Result<Vec<String>> {
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
}
@@ -832,7 +837,11 @@ fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
Ok(result)
}
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec<String> {
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
/// links; lost links keep their last geometry. A link kept by the name of a face or edge that no
/// longer finds exactly that face or edge is not lost: the sketch fails, asking for the
/// reference to be picked again ([`DocError::Reselect`]).
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Result<Vec<String>> {
refresh_links_in(doc, vals, st, plane, sk, None)
}
@@ -869,7 +878,7 @@ fn refresh_links_in(
plane: &Plane,
sk: &mut Sketch,
anchor: Option<&crate::FaceAnchor>,
) -> Vec<String> {
) -> Result<Vec<String>> {
let mut warn = Vec::new();
let links = sk.links.clone();
for l in &links {
@@ -878,45 +887,18 @@ fn refresh_links_in(
if !l.auto_face
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
{
if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) {
let stem = crate::naming::strip_piece(name);
let removed_piece = split
&& name != stem
&& st.body(body).is_some_and(|target| {
(certified_planar_construction(target, index) || target.body.face_was_coalesced(index))
&& crate::naming::face_has_name(target, index, stem)
});
if removed_piece {
sk.set_link_lost(&l.id, true);
warn.push(format!("projected face piece `{name}` no longer exists; last geometry retained"));
continue;
}
*at = p;
named_face = Some((body.clone(), index, p));
if split {
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
}
} else {
sk.set_link_lost(&l.id, true);
warn.push(format!("projected face `{name}` no longer exists"));
continue;
}
let (index, p) =
st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)).ok_or_else(|| DocError::reselect(name.as_str()))?;
*at = p;
named_face = Some((body.clone(), index, p));
}
// 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.
// edge has the name any more no other edge is taken for it: the sketch fails.
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 (body, hit) = locate_named_edge(st, body, name, *at).ok_or_else(|| DocError::reselect(name.as_str()))?;
warn.extend(hit.note(name).map(|note| format!("projected {note}")));
named_edge = Some((body, hit.edge, *at));
}
let resolved = if matches!(source, LinkSource::Text { .. }) {
// A text needs nothing outside its sketch; one on a path follows a curve of it.
@@ -974,7 +956,7 @@ fn refresh_links_in(
}
}
}
warn
Ok(warn)
}
// ---------------------------------------------------------------------------------------------
@@ -1168,12 +1168,17 @@ fn prism_edge_in_a_round_cornered_slab_end_face_joins() {
/// of the bodies as they lie left the hole closed by a skin of no thickness (the plate's top face
/// whole over the hole, the tool's end face under it), which no volume shows and which was kept.
/// The hole is open, and the cut takes the whole tool: the circle of radius 3.04 the two
/// profiles make, from the barrel cut's floor at z 1.6 up to the plate's top at z 2.8.
/// profiles make, from the barrel cut's floor at z 1.6 up to the plate's top at z 2.8. The design
/// was saved by an older build: Extrude13 starts from a piece of a face that is no longer that
/// piece, so it fails, asking for its face again; the rest builds.
#[test]
fn cut_up_to_the_plate_top_face_opens_the_hole() {
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/cut_up_to_face_leaves_skin.json")).unwrap();
let s = replay_value(&script);
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
let failed =
|r: &solidcraft_doc::FeatureResult| r.error.as_deref().and_then(solidcraft_doc::reselect_parts).map(|(feature, _)| feature.to_string());
assert!(s.model.results.iter().all(|r| r.error.is_none() || failed(r).as_deref() == Some("Extrude13")), "{:?}", s.model.results);
assert!(s.model.results.iter().any(|r| failed(r).as_deref() == Some("Extrude13")));
let state = s.model.state();
let body = &state.body("Body7").unwrap().body;
assert!(body.validity().is_empty(), "{:?}", body.validity());
-1
View File
@@ -498,7 +498,6 @@ pub(super) fn paste(
}
if let Some(name) = &face.name {
face.point = solidcraft_doc::naming::point_on_face(&state, name, face.point)
.map(|(point, _)| point)
.ok_or_else(|| bad(CMD, "source frozen face appearance lost its identity"))?;
}
let source_body = state.body(&face.body).ok_or_else(|| bad(CMD, "source frozen face appearance lost its body"))?;
@@ -479,7 +479,7 @@ fn transferred_frozen_face_paint_ignores_consumed_legacy_offset() {
let part = component(&mut s, "Part");
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]}));
let original = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign").unwrap();
let expected = solidcraft_doc::naming::point_on_face(&s.model.state(), original.name.as_ref().unwrap(), original.point).unwrap().0;
let expected = solidcraft_doc::naming::point_on_face(&s.model.state(), original.name.as_ref().unwrap(), original.point).unwrap();
copy(&mut s, part);
let paint = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign (2)").unwrap();
assert!(paint.point.dist(expected) < 1e-6, "{:?} versus {expected:?}", paint.point);
+2 -2
View File
@@ -131,10 +131,10 @@ fn keep_component_looks(doc: &mut Document, owners: &BTreeMap<String, u64>, comp
/// What the bodies `new` call the face or edge that `old` calls `name`, found where it is.
fn renamed(old: &ModelState, new: &[&ModelBody], name: &str, near: Vec3) -> Option<String> {
use solidcraft_doc::{appearance::face_index_at, naming};
if let Some((p, _)) = naming::point_on_face(old, name, near) {
if let Some(p) = naming::point_on_face(old, name, near) {
return new.iter().find_map(|b| face_index_at(b, p).and_then(|i| naming::face_names(b).get(i).cloned()));
}
let p = old.bodies.iter().find_map(|b| naming::find_edge(b, name, near).ok())?.0;
let p = old.bodies.iter().find_map(|b| naming::find_edge(b, name, near).ok())?;
new.iter().find(|b| face_index_at(b, p).is_some()).and_then(|b| naming::edge_names_at(b, &[p]).pop())
}
+13 -8
View File
@@ -1829,26 +1829,31 @@ fn a_line_of_text_cuts_into_a_block_at_any_depth() {
/// A design saved before tapered sides were built through `taper_loop` (#1014), by the build
/// before it: two tapered bosses apart from each other (now joined in one boolean), a fillet on
/// a sloping side edge of one and on the top edge of the other, both saved by edge name. They
/// still find their edges, and the part comes out the same.
/// a sloping side edge of one and on the top edge of the other, both saved by edge name. The
/// sloping edge is still the edge its name names. The top edge was saved as an edge of piece `#1`
/// of the cone's side, which is one face now: no edge is that edge any more, so that fillet fails
/// and asks for its edge to be picked again (never the nearest edge of the whole face), and the
/// rest of the part comes out as before.
#[test]
fn fillets_on_tapered_bosses_saved_before_still_find_their_edges() {
fn a_fillet_saved_on_a_piece_of_a_tapered_side_that_is_whole_now_asks_for_its_edge_again() {
let text = include_str!("../../../../tests/taper/before-1014/two_tapered_bosses_filleted.json");
let mut s = Session::new(solidcraft_doc::Document::from_json(text).unwrap());
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
for name in ["Extrude2", "Fillet1", "Fillet2"] {
for name in ["Extrude2", "Fillet1"] {
let id = s.doc.find_feature(name).unwrap().id;
let r = s.model.result(id).unwrap();
assert!(r.error.is_none() && r.warning.is_none(), "{name}: {:?} {:?}", r.error, r.warning);
}
let fillet = s.doc.find_feature("Fillet2").unwrap().id;
let error = s.model.result(fillet).unwrap().error.clone().unwrap();
assert_eq!(solidcraft_doc::reselect_parts(&error), Some(("Fillet2", "F4:side:c1#1|F4:top#[F4:side:c1]")), "{error}");
// In closed form: the 80 × 40 × 20 block, the 20 × 20 boss 10 high tapered 5° in (a frustum,
// top 20 − 20 tan 5° square: 3660.251034), the Ø16 boss tapered alike (a cone frustum, top
// radius 8 − 10 tan 5°: 1798.751833); less the R1 round along a sloping corner (the corner's
// section, r²(cot φ/2 − (π − φ)/2) at its dihedral angle φ = 90.435°, times its length
// between z = 20 and 30: 2.124401) and the R0.5 round all round the cone's top (the section
// at the rim's 95°, turned about the axis by Pappus: 1.928463). The build that saved it,
// with truck, measured 69454.885209.
assert!((volume - 69454.950004).abs() < 1e-3, "{volume}");
// between z = 20 and 30: 2.124401). The R0.5 round all round the cone's top (1.928463) is
// the failed fillet's.
assert!((volume - 69456.878467).abs() < 1e-3, "{volume}");
assert_eq!(s.world_state().unwrap().bodies.len(), 1);
}
+1 -2
View File
@@ -509,8 +509,7 @@ mod tests {
let before = s.model.state_before(feature.id);
let name = feature.face_names.first().unwrap();
let point = solidcraft_doc::naming::point_on_face(&before, name, Vec3::new(0., -28., 20.))
.expect("the original wall identity survives merged pieces")
.0;
.expect("the original wall identity survives merged pieces");
assert!((point - Vec3::new(0., -33., 20.)).len() < 1e-8, "{point:?}");
assert!(s.model.results.iter().all(|result| result.error.is_none()));
let check = |session: &Session| {
+7 -1
View File
@@ -194,10 +194,16 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
return vec!["lost face attachment; reattach explicitly".into()];
};
let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() };
let warnings = match attachment {
let refreshed = match attachment {
Some(anchor) => solidcraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor),
None => solidcraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk),
};
// A link whose named face or edge is gone fails the sketch when it is evaluated; while it is
// edited, that is what the refresh says.
let warnings = match refreshed {
Ok(warnings) => warnings,
Err(e) => return vec![e.in_feature(&doc.feature(id).map(|f| f.name.clone()).unwrap_or_default()).to_string()],
};
if let Ok(stored) = doc.sketch(id)
&& sk.positions_in(stored).is_some()
{
@@ -1,6 +1,6 @@
//! 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.
//! is taken for it when it is split or gone: the sketch fails, asking for the edge again.
use serde_json::{Value, json};
use solidcraft_geom::{Vec2, Vec3};
@@ -119,11 +119,17 @@ fn a_projected_edge_stays_that_edge_when_the_body_changes_size() {
assert!(matches!(&stored(&s).source, LinkSource::Edge { body, at } if body == "Body1" && at.dist(Vec3::new(400.0, 25.0, 20.0)) < 1e-6));
}
/// What sketch `Top` asks to be picked again, when it fails for a reference it lost.
fn reselect(s: &Session) -> Option<String> {
let r = s.model.result(s.doc.find_feature("Top").unwrap().id).unwrap();
r.error.as_deref().and_then(solidcraft_doc::reselect_parts).filter(|(feature, _)| *feature == "Top").map(|(_, reference)| reference.to_string())
}
/// 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.
/// pieces, y = 0..20 and y = 30..50. Neither is the edge: the sketch fails, asking for it again
/// (never the piece nearest the pick); without the slot it is the whole edge again.
#[test]
fn a_projected_edge_that_was_split_gives_its_nearest_piece_and_says_so() {
fn a_projected_edge_that_was_split_fails_its_sketch_asking_for_the_edge_again() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
@@ -133,31 +139,28 @@ fn a_projected_edge_that_was_split_gives_its_nearest_piece_and_says_so() {
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");
let name = stored(&s).edge_name.unwrap();
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);
assert_eq!(reselect(&s), Some(name), "{}", trouble(&s));
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.
/// point, the nearest edges there are now: the sketch takes neither of them, it fails, asking for
/// its edge again. Without the fillet the link has its edge again.
#[test]
fn a_projected_edge_that_is_gone_is_lost_not_replaced() {
fn a_projected_edge_that_is_gone_fails_its_sketch_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");
let name = stored(&s).edge_name.unwrap();
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");
assert_eq!(reselect(&s), Some(name), "{}", trouble(&s));
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
assert_eq!(trouble(&s), "");
assert!(!link(&s).lost);
+15 -12
View File
@@ -304,21 +304,20 @@ fn deleting_one_edge_of_a_projected_face_keeps_the_others_as_they_were() {
assert!(s.doc.sketch(id).unwrap().links.is_empty());
}
/// The edge a projection was made from goes with its body: the sketch fails, asking for the edge
/// again, instead of keeping a lost link.
#[test]
fn lost_reference_keeps_geometry_with_a_warning() {
fn a_reference_whose_body_is_gone_fails_its_sketch_asking_for_it_again() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let boxf = s.doc.features.last().unwrap().id;
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [5, 0, 10]}]}));
run(&mut s, "sketch.finish", json!({}));
let name = s.doc.sketch(s.doc.features.last().unwrap().id).unwrap().links[0].edge_name.clone().unwrap();
run(&mut s, "timeline.suppress", json!({"feature": boxf, "suppressed": true}));
let st = s.model.state();
let ss = st.sketches.last().unwrap();
assert_eq!(ss.sketch.curves.len(), 1, "geometry kept");
assert!(ss.sketch.links[0].lost);
let w = s.model.results.last().unwrap().warning.clone().unwrap_or_default();
assert!(w.contains("lost"), "{w}");
let r = s.model.results.last().unwrap();
assert_eq!(r.error.as_deref().and_then(solidcraft_doc::reselect_parts), Some((r.name.as_str(), name.as_str())), "{r:?}");
}
#[test]
@@ -558,8 +557,8 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
}
/// #47: references to other bodies: an edge, a face's loops and a silhouette of a second body
/// project into a sketch on the first; they follow an edit of that body (a parameter) and
/// are reported lost, not crashed on, when it goes away.
/// project into a sketch on the first; they follow an edit of that body (a parameter), and when
/// it goes away the sketch fails, asking for the named edge again, not crashed on.
#[test]
fn project_and_include_from_other_bodies() {
let mut s = Session::default();
@@ -589,13 +588,17 @@ fn project_and_include_from_other_bodies() {
let id = s.doc.features.iter().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
let si = run(&mut s, "sketch.inspect", json!({"sketch": id}));
assert!(xs(&si).iter().any(|x| (x - 70.0).abs() < 1e-6), "{:?}", xs(&si));
// Remove the cylinder: its silhouette and edge are lost, with a warning and no error.
// Remove the cylinder: the edge included from it is gone, and the sketch asks for it again.
let cyl = s.doc.features.iter().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::Cylinder { .. })).unwrap().id;
run(&mut s, "timeline.delete", json!({"feature": cyl}));
let t = run(&mut s, "document.inspect", json!({}));
let row = t["timeline"].as_array().unwrap().iter().find(|r| r["id"] == json!(id)).cloned().unwrap();
assert!(row["error"].is_null(), "{row}");
assert!(row["warning"].as_str().is_some_and(|w| w.contains("lost")), "{row}");
let error = row["error"].as_str().unwrap_or_default();
assert!(
solidcraft_doc::reselect_parts(error)
.is_some_and(|(feature, reference)| feature == row["name"] && reference.starts_with(&format!("F{cyl}:"))),
"{row}"
);
}
/// A cylinder's wall and the torus of a rounded rim are drawn as one face but stored in halves
+22 -13
View File
@@ -27,6 +27,12 @@ fn error(s: &Session, name: &str) -> Option<String> {
s.model.result(id(s, name)).and_then(|r| r.error.clone())
}
/// The reference feature `name` failed on, asking for it to be picked again.
fn reselect(s: &Session, name: &str) -> Option<String> {
let error = error(s, name)?;
solidcraft_doc::reselect_parts(&error).filter(|(feature, _)| *feature == name).map(|(_, reference)| reference.to_string())
}
/// Whether any body holds `p`.
fn solid_at(s: &Session, p: [f64; 3]) -> bool {
s.model.state().bodies.iter().any(|b| b.body.contains(Vec3::new(p[0], p[1], p[2])))
@@ -171,14 +177,16 @@ fn a_piece_keeps_its_identity_when_the_cut_that_made_it_moves_or_shrinks() {
change(&mut s, "slot_w", "0.05 mm");
assert_eq!(warning(&s, "Raise"), None);
assert!(solid_at(&s, [39.0, 15.0, 11.0]) && !solid_at(&s, [27.0, 15.0, 11.0]));
// Gone altogether (suppressed), the piece is the whole top again: said, not guessed.
// Gone altogether (suppressed), the top is whole again, which is not the piece: the raise
// fails, asking for its face again, never guessed.
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
let w = warning(&s, "Raise").unwrap_or_default();
assert!(w.contains("split") || w.contains("no longer exists"), "{w}");
assert_eq!(reselect(&s, "Raise").as_ref(), raise.face_names.first());
}
/// A face merged into another is not the face that was picked: the feature on it fails, asking
/// for its face again; when the faces part again, the reference finds its face.
#[test]
fn a_face_merged_into_another_answers_to_its_name_and_says_so() {
fn a_face_merged_into_another_asks_for_its_face_again() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "boss_h", "expression": "5 mm"}));
@@ -191,13 +199,13 @@ fn a_face_merged_into_another_answers_to_its_name_and_says_so() {
let raise = s.doc.find_feature("Raise").cloned().unwrap();
assert_eq!(raise.face_names, vec![format!("F{boss}:top")]);
assert!(solid_at(&s, [50.0, 15.0, 5.5]) && !solid_at(&s, [50.0, 15.0, 6.5]));
// As tall as the block, the boss's top and the block's are one face: the reference finds
// it and says it was merged.
// As tall as the block, the boss's top and the block's are one face: not the boss's top.
change(&mut s, "boss_h", "10 mm");
assert_eq!(reselect(&s, "Raise").as_ref(), raise.face_names.first());
assert!(!solid_at(&s, [50.0, 15.0, 10.5]) && !solid_at(&s, [20.0, 15.0, 10.5]), "nothing is raised");
change(&mut s, "boss_h", "5 mm");
assert_eq!(error(&s, "Raise"), None);
let w = warning(&s, "Raise").unwrap_or_default();
assert!(w.contains("merged"), "{w}");
assert!(solid_at(&s, [50.0, 15.0, 10.5]) && solid_at(&s, [20.0, 15.0, 10.5]), "the merged top is raised as one");
assert!(solid_at(&s, [50.0, 15.0, 5.5]) && !solid_at(&s, [20.0, 15.0, 10.5]));
}
#[test]
@@ -630,9 +638,9 @@ fn one_fillet_over_two_bodies_of_one_extrude_rounds_each_body_s_own_edges() {
/// One fillet over a vertical edge of each of two stacked boxes. A build before blends were split
/// by body named the upper box's edge after the lower box's nearest edge (its top front edge),
/// which was rounded instead. The pick (outside the box the name was recorded on) shows that name
/// is not the upper box's: the edge is found at its point, on the upper box.
/// is not the upper box's edge: the fillet fails, asking for that edge again, and rounds nothing.
#[test]
fn one_fillet_over_stacked_boxes_rounds_the_upper_box_s_edge_even_when_saved_named_on_the_lower() {
fn one_fillet_over_stacked_boxes_saved_named_on_the_lower_asks_for_the_upper_edge_again() {
let mut s = Session::default();
stacked_boxes(&mut s);
run(&mut s, "solid.fillet", json!({"edges": [[25, -25, 20], [-25, -25, 45]], "radius": 2, "name": "Rounds"}));
@@ -655,10 +663,11 @@ fn one_fillet_over_stacked_boxes_rounds_the_upper_box_s_edge_even_when_saved_nam
let old_refs =
solidcraft_doc::edge_refs(&before, &Some(lower.name.clone().into()), &[Vec3::new(25.0, -25.0, 20.0), Vec3::new(-25.0, -25.0, 45.0)]);
let feature = s.doc_mut().feature_mut(f).unwrap();
feature.edge_names = old_names;
feature.edge_names = old_names.clone();
feature.edge_refs = old_refs;
s.refresh();
check(&s, "saved by an older build");
assert_eq!(reselect(&s, "Rounds").as_ref(), old_names.get(1));
assert!(solid_at(&s, [24.8, -24.8, 20.0]) && solid_at(&s, [-24.8, -24.8, 45.0]), "nothing is rounded");
}
/// A shell opened by a point pick on a bar's end face keeps opening that face when a parameter
+16 -7
View File
@@ -2635,6 +2635,13 @@ mod naming {
s.model.result(id).and_then(|r| r.warning.clone())
}
/// The reference feature `name` failed on, asking for it to be picked again.
fn reselect(s: &Session, name: &str) -> Option<String> {
let id = s.doc.find_feature(name).map(|f| f.id)?;
let error = s.model.result(id).and_then(|r| r.error.clone())?;
solidcraft_doc::reselect_parts(&error).filter(|(feature, _)| *feature == name).map(|(_, reference)| reference.to_string())
}
/// A 40 x 30 x 10 block (sketch S: l1…l4 counter-clockwise from the origin), a hole H, a
/// fillet on the right top edge.
fn block() -> Session {
@@ -2692,8 +2699,10 @@ mod naming {
assert_eq!(warning(&s, "Round"), None);
}
/// The notch leaves the filleted edge in two pieces: neither is the edge, so the fillet fails,
/// asking for its edge again, and never rounds the piece nearest the pick.
#[test]
fn a_split_edge_warns_and_takes_the_nearest_piece() {
fn a_split_edge_fails_the_fillet_asking_for_the_edge_again() {
let mut s = block();
// Upstream of the fillet, a notch through the middle of the filleted edge.
run(&mut s, "timeline.roll_to", json!({"feature": "Hole"}));
@@ -2702,12 +2711,13 @@ mod naming {
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "N", "distance": 10, "operation": "cut", "name": "Notch"}));
run(&mut s, "timeline.roll_to", json!({}));
let w = warning(&s, "Round").unwrap_or_default();
assert!(w.contains("split"), "{w}");
assert_eq!(reselect(&s, "Round").as_deref(), Some("F2:side:l2|F2:top"));
}
/// The face the filleted edge bounded is gone with its sketch line: the fillet fails, asking
/// for its edge again.
#[test]
fn a_removed_segment_warns() {
fn a_removed_segment_fails_the_fillet_asking_for_the_edge_again() {
let mut s = block();
// Replace the right side (l2) of the rectangle with a new line: the face it made is gone.
let sk = s.doc.find_feature("S").map(|f| f.id).unwrap();
@@ -2716,8 +2726,7 @@ mod naming {
run(&mut s, "sketch.line", json!({"points": [[40, 0], [40, 30]], "ids": ["right"]}));
let r: Result<Value, _> = s.execute("sketch.finish", &json!({}));
assert!(r.is_ok() || r.is_err());
let w = warning(&s, "Round").unwrap_or_default();
assert!(w.contains("no longer exists"), "{w}");
assert_eq!(reselect(&s, "Round").as_deref(), Some("F2:side:l2|F2:top"));
}
fn face_names(s: &mut Session, body: &str) -> Vec<String> {
@@ -4454,7 +4463,7 @@ fn a_side_drawn_in_two_collinear_pieces_extrudes_into_one_face() {
let st = s.model.state();
let body = st.body("Body1").unwrap();
let edge = format!("F2:side:{}|F2:top", pieces[1]);
let (at, _) = solidcraft_doc::naming::find_edge(body, &edge, geom::Vec3::new(15.0, 0.0, 5.0)).unwrap();
let at = solidcraft_doc::naming::find_edge(body, &edge, geom::Vec3::new(15.0, 0.0, 5.0)).unwrap();
assert!(at.y.abs() < 1e-9 && (at.z - 5.0).abs() < 1e-9, "{edge} at {at:?}");
// A sketch attaches to the joined side as to any side.
assert_eq!(run(&mut s, "sketch.create", json!({"plane": {"face": [15, 0, 2.5]}}))["attachment"], "exact_face");
+1 -2
View File
@@ -5117,8 +5117,7 @@ fn named_refill(s: &Session, body: Option<&str>, name: &str, point: Vec3, edge:
.filter_map(|b| naming::locate_edge(b, name, point).ok().map(|hit| (b, hit)))
.min_by(|(_, a), (_, b)| a.point.dist(point).total_cmp(&b.point.dist(point)))?;
(b, hit.edge, hit.point)
} else if let Some((b, (index, point, _))) = body.and_then(|n| st.body(n)).and_then(|b| naming::face_on_body(b, name, point).map(|hit| (b, hit)))
{
} else if let Some((b, (index, point))) = body.and_then(|n| st.body(n)).and_then(|b| naming::face_on_body(b, name, point).map(|hit| (b, hit))) {
(b, index, point)
} else {
let hit = naming::locate_face(&st, name, point)?;
+1 -1
View File
@@ -742,7 +742,7 @@ fn draft_uses_the_picked_solid_beside_a_hidden_overlapping_mesh() {
let target = body.name.clone();
let point = Vec3::new(20.0, 10.0, 5.0);
let name = naming::face_name_at_body(body, point).unwrap();
let (index, _, _) = naming::face_on_body(body, &name, point).unwrap();
let (index, _) = naming::face_on_body(body, &name, point).unwrap();
h.app.session.selection = vec![Sel::Face { body: target.clone(), index, point }];
let mut d = Dialog::for_command(&h.app, "solid.draft").unwrap();
d.inputs[1].items = vec![Sel::Plane { name: "XY".into() }];