doc: names saved before format 14 find nothing; their features ask to reselect

Face and edge names stored before format 14 were worked out from geometry and
open marked `?`. They were found among the names geometry gives today, as the
builds that saved them found them, pieces by the one nearest the pick. They
find nothing now: the feature holding one fails, asking for its reference to be
picked again, which stores its exact name. The legacy face and edge names a
body worked out for them are gone.

The tests that found such names as saved now assert the request to reselect:
a saved strip projection, a version 9 emboss on an annulus, a format 10 face
sketch and the corpus design named with numbered pieces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 12:18:14 -04:00
committed by jyviko
parent cfa6f38d6e
commit ef37bd4459
11 changed files with 111 additions and 238 deletions
+19 -11
View File
@@ -51,8 +51,9 @@ fn old_designs_open_and_evaluate_as_saved() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let want: Value = serde_json::from_slice(&std::fs::read(p.with_extension("expected.json")).unwrap()).unwrap();
let mut s = open(&p);
// A name saved before format 14 finds nothing: its feature asks for it to be picked again.
for r in &s.model.results {
if let Some(e) = &r.error {
if let Some(e) = r.error.as_deref().filter(|e| solidcraft_engine::doc::reselect_parts(e).is_none()) {
problems.push(format!("{name}: feature {}: {e}", r.id));
}
}
@@ -94,23 +95,28 @@ fn old_designs_open_and_evaluate_as_saved() {
}
/// Names saved before format 14 were worked out from geometry, pieces of a split face numbered by
/// where their centres lay (`F2:top#2`). They keep naming the faces they named: marked as such,
/// they are found as the build that saved them found them, without new notes.
/// where their centres lay (`F2:top#2`). They open marked as such and find no face or edge today:
/// each feature holding one fails, asking for its reference to be picked again, and the rest of
/// the design builds.
#[test]
fn names_saved_before_exact_history_find_the_faces_they_named() {
fn names_saved_before_exact_history_ask_for_their_faces_again() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solidcraft");
let s = open(&path);
let feature = |name: &str| s.doc.features.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name}"));
assert_eq!(feature("Round").edge_names, vec!["?F2:side:l2|F2:top#2".to_string()]);
assert_eq!(feature("Bevel").edge_names, vec!["?F2:side:l4|F2:top#1".to_string()]);
assert_eq!(feature("Raise").face_names, vec!["?F2:top#1".to_string()]);
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
let asked = |name: &str| {
let r = s.model.result(feature(name).id).unwrap_or_else(|| panic!("{name}"));
r.error.as_deref().and_then(solidcraft_engine::doc::reselect_parts).map(|(f, reference)| (f.to_string(), reference.to_string()))
};
assert_eq!(asked("Round"), Some(("Round".into(), "?F2:side:l2|F2:top#2".into())));
assert_eq!(asked("Bevel"), Some(("Bevel".into(), "?F2:side:l4|F2:top#1".into())));
assert_eq!(asked("Raise"), Some(("Raise".into(), "?F2:top#1".into())));
for r in s.model.results.iter().filter(|r| !["Round", "Bevel", "Raise"].contains(&r.name.as_str())) {
assert!(r.error.is_none(), "{}: {:?}", r.name, r.error);
}
// The raised piece is the left one, as saved (the stored point is on it).
let state = s.model.state();
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0]));
assert!(!s.model.state().bodies.is_empty());
}
/// Body names saved before format 14 name the pieces of a split body in the kernel's shell order.
@@ -173,6 +179,7 @@ fn flat_face_attachments() -> Vec<(String, String, usize, Option<(u32, bool)>, O
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let mut s = open(&p);
let failed_on_open: Vec<u64> = s.model.results.iter().filter(|r| r.error.is_some()).map(|r| r.id).collect();
let state = s.model.state();
let placed = s.placed_document();
let mut picks = Vec::new();
@@ -224,7 +231,8 @@ fn flat_face_attachments() -> Vec<(String, String, usize, Option<(u32, bool)>, O
let known = known_without_attachment(&name, &body, normal, distance);
out.push((name.clone(), body, index, known, problem));
}
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{name}: left with errors after undo");
let failed: Vec<u64> = s.model.results.iter().filter(|r| r.error.is_some()).map(|r| r.id).collect();
assert_eq!(failed, failed_on_open, "{name}: undo leaves the features that failed on opening, and only those, failed");
}
out
}
+2 -4
View File
@@ -175,9 +175,7 @@ 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.verified, "geometric"), (hit.legacy, "legacy")])), true)
}
Some(hit) => (format!("{}{}", face_label(&fst, hit.body, hit.face), flags(&[(!hit.verified, "geometric")])), true),
None => ("gone".into(), false),
}
};
@@ -193,7 +191,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.verified, "geometric"), (hit.legacy, "legacy")])
flags(&[(!hit.verified, "geometric")])
),
true,
),
+9 -18
View File
@@ -230,7 +230,7 @@ fn regularized_shell_keeps_legacy_and_explicit_single_face_projections_closed()
}
#[test]
fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material() {
fn saved_former_strip_projection_asks_for_its_face_again_without_expanding_healthy_material() {
let (mut doc, _, _) = shell_document();
let mut model = Model::new();
model.evaluate(&doc);
@@ -257,10 +257,9 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
)
.unwrap();
sketch.links[0].face_name = Some(former.clone());
let original = sketch.clone();
let sk = doc
.add_feature(
FeatureKind::Sketch { plane: PlaneRef::Face { plane, at: Vec3::new(20., 6., 1.), name: Some(former), anchor: None }, sketch },
FeatureKind::Sketch { plane: PlaneRef::Face { plane, at: Vec3::new(20., 6., 1.), name: Some(former.clone()), anchor: None }, sketch },
Some("Saved strip sketch"),
)
.unwrap();
@@ -288,21 +287,13 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
let mut cold = Model::new();
cold.evaluate(&loaded);
for current in [&model, &cold] {
let warning = current.results.iter().find(|r| r.id == sk).unwrap().warning.as_deref().unwrap();
assert!(warning.contains("no longer exists; last geometry retained"), "{warning}");
let state = current.state();
let solved = state.sketch(sk).unwrap();
assert!(solved.sketch.links[0].lost);
assert_eq!(solved.sketch.curves, original.curves);
assert_eq!(solved.sketch.points, original.points);
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
assert!(
(solidcraft_kernel::measure(&material.body).unwrap().volume - 24.).abs() < 1e-8,
"old24mm3 profile must not silently become120mm3 wall profile"
);
let bounds = material.mesh().bounds();
assert!((bounds.min.x - width).abs() < 1e-8 && (bounds.max.x - width - 1.).abs() < 1e-8);
assert!((bounds.max.y - 12.).abs() < 1e-8 && (bounds.max.z - 2.).abs() < 1e-8);
// The name was saved before format 14 (marked): it finds no face, so the sketch on it
// fails, asking for its face again, and so does the material made from it.
let result = current.results.iter().find(|r| r.id == sk).unwrap();
let reference = format!("{}{former}", crate::naming::LEGACY_MARK);
assert_eq!(result.error.as_deref().and_then(crate::reselect_parts), Some(("Saved strip sketch", reference.as_str())), "{result:?}");
assert!(current.results.iter().find(|r| r.id == boss).unwrap().error.is_some());
assert!(current.state().bodies.iter().all(|body| body.feature != boss), "old 24 mm3 profile must not silently become the 120 mm3 wall");
}
}
}
+1 -5
View File
@@ -6277,11 +6277,7 @@ fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) ->
if !face.plane_normal.is_some_and(|fnrm| fnrm.dot(n) > 1.0 - 1e-6) {
return Err(DocError::reselect(name));
}
// 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
{
let note = if !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 {
+2 -2
View File
@@ -387,8 +387,8 @@ const JSON_STEPS: [JsonStep; HAND_WRITTEN as usize - 1] = [
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
/// legacy mark (`?`, see `naming::LEGACY_MARK`) so they are found again as those builds found
/// them, and an older design evaluates as it was saved. The step marks the fields that store
/// legacy mark (`?`, see `naming::LEGACY_MARK`), which finds no face or edge: a feature holding
/// one fails, asking for its reference to be picked again. The step marks the fields that store
/// such a name, by their place in the schema:
/// - each feature's `face_names`, and fillets' and chamfers' `edge_names` (other features' edge
/// names are sheet metal's own);
+2 -3
View File
@@ -37,9 +37,8 @@ fn bad(what: &str) -> DocError {
DocError::Invalid(what.into())
}
/// A design whose stored face or edge names were saved before format 14 (marked
/// [`crate::naming::LEGACY_MARK`]) finds them through names worked out from geometry on demand,
/// which a saved model does not keep.
/// A design holding a face or edge name saved before format 14 (marked
/// [`crate::naming::LEGACY_MARK`]): it saves without a model.
fn has_legacy_names(doc: &Document) -> bool {
let mark = format!("\"{}", crate::naming::LEGACY_MARK);
doc.features.iter().any(|f| match &f.kind {
+37 -110
View File
@@ -4,25 +4,24 @@
//! 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.
//! face, when only a face of the same stem is left, when the name is on several bodies, or when
//! the name was saved before format 14 ([`LEGACY_MARK`]), the name finds nothing and the feature
//! that uses it fails, asking for the reference to be picked again
//! ([`crate::DocError::Reselect`]). 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};
use crate::history_names::{Match, Names};
use crate::naming::{self, closest, edge_names, face_names, legacy_edge_names, legacy_face_names, strip_piece};
use crate::piece_names::{strip_edge_number, strip_edge_piece};
use crate::naming::{self, closest, edge_names, face_names, strip_piece};
use crate::piece_names::strip_edge_number;
use crate::{ModelBody, ModelState};
/// The mark on a name saved before format 14 (`?F2:top#2`). Those names were worked out from
/// geometry, and a few of them name a face that exact history names differently (a tube's outer
/// wall was named after the nearest sketch curve to its centre, its inner circle): such a name is
/// found again among the names that geometry gives today, as the build that saved it did, so an
/// older design evaluates as it was saved. Picking the face again stores its exact name.
/// geometry, and some of them name a face that exact history names differently (a tube's outer
/// wall was named after the nearest sketch curve to its centre, its inner circle): such a name
/// finds no face, and the feature using it asks for the face to be picked again, which stores
/// its exact name.
pub const LEGACY_MARK: char = '?';
/// A name saved before format 14, without its mark (`None`: an exact name).
@@ -42,16 +41,14 @@ pub struct EdgeHit {
pub verified: bool,
/// Whether the reference's point still lies on the edge.
pub on_point: bool,
/// The name was saved before format 14 and found as that build did ([`LEGACY_MARK`]).
pub legacy: bool,
}
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
/// between the faces it names exactly (see [`exactly`]). `Err` explains when there is none: no
/// such edge, or the named edge split into several.
/// such edge, the named edge split into several, or a name saved before format 14.
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"));
if legacy_name(name).is_some() {
return Err(format!("edge `{name}` was named by a build before format 14"));
}
let m = b.mesh();
let names = edge_names(b);
@@ -64,9 +61,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 }, verified: verified(i), on_point: on, legacy: false })
.ok_or_else(|| "edge".into())
mid(i).map(|p| EdgeHit { edge: i, point: if on { near } else { p }, verified: verified(i), on_point: on }).ok_or_else(|| "edge".into())
};
if let Some(i) = names.iter().position(|n| n == name) {
return exact(i);
@@ -134,25 +129,6 @@ fn edge_mid(m: &Mesh, i: usize) -> Option<Vec3> {
e.first().copied()
}
/// [`locate_edge`] for a name saved before format 14 (without its mark), as those builds found
/// it: the edge with that name among the names geometry gives, else the piece of it nearest
/// `near`.
fn legacy_locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, ()> {
let m = b.mesh();
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 }, 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, verified: false, on_point: false, legacy: true })
.ok_or(())
}
/// How well the stored edge name `wanted` (without its own number) names the edge between faces
/// `a` and `b`: the weaker of its two faces' matches, read every way the name splits into two
/// face names (a face name may hold `|` itself: a fillet face is named after its edge).
@@ -192,15 +168,13 @@ pub struct FaceHit {
/// Whether the reference's point still lies on the face (so a name worked out from geometry
/// and the pick agree).
pub on_point: bool,
/// The name was saved before format 14 and found as that build did ([`LEGACY_MARK`]).
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.
/// left, or the name was saved before format 14.
Gone,
/// Several faces answer to it as well: the pieces of a split face, or the same face on
/// several bodies.
@@ -210,11 +184,7 @@ enum Miss {
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.verified && !self.on_point && !self.legacy {
Some(format!("face `{name}` was found by geometry, not exact history; check it"))
} else {
None
}
if !self.verified && !self.on_point { Some(format!("face `{name}` was found by geometry, not exact history; check it")) } else { None }
}
}
@@ -320,11 +290,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.verified && !self.on_point && !self.legacy {
Some(format!("edge `{name}` was found by geometry, not exact history; check it"))
} else {
None
}
if !self.verified && !self.on_point { Some(format!("edge `{name}` was found by geometry, not exact history; check it")) } else { None }
}
}
@@ -334,11 +300,12 @@ 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).ok();
}
if legacy_name(name).is_some() {
return None;
}
// 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()));
}
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).ok()
}
@@ -378,13 +345,13 @@ fn sheet_locate(st: &ModelState, name: &str, p: Vec3) -> Result<FaceHit, Miss> {
_ => 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 })
Ok(FaceHit { body: bi, face, point, verified: true, on_point: distance <= (b.body.size() * 1e-3).max(1e-3) })
}
/// [`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).ok_or(Miss::Gone);
if legacy_name(name).is_some() {
return Err(Miss::Gone);
}
let mut best: Option<(Match, Vec<(usize, &ModelBody, usize)>)> = None;
for (bi, b) in bodies {
@@ -398,14 +365,7 @@ fn locate_in<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &st
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,
})
Some(FaceHit { body, face, point, verified: naming::names(b).verified(face), on_point: distance <= (b.body.size() * 1e-3).max(1e-3) })
};
if let [one] = faces.as_slice() {
return found(*one).ok_or(Miss::Gone);
@@ -424,54 +384,21 @@ fn locate_in<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &st
}
}
/// [`locate_face`] for a name saved before format 14 (without its mark), as those builds found
/// it: among the names geometry gives, the face with that name (or a piece of the same face, if
/// 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, f64)> = None;
for (bi, b) in bodies {
let names = legacy_face_names(b);
let tol = (b.body.size() * 1e-3).max(1e-3);
let m = b.mesh();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some(n) = names.get(*f as usize) else { continue };
let exact = n == name;
if !exact && strip_piece(n) != stem {
continue;
}
let Some([a, bb, c]) = m.tri(t) else { continue };
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, tol));
}
}
}
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 exactly the face a stored name names (see [`exactly`]); 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`]). A
/// name saved before format 14 names no face.
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(exactly),
}
legacy_name(name).is_none() && crate::history_names::matches(&naming::names(b), index, name).is_some_and(exactly)
}
/// The faces of a body that are the stored name's face or pieces of it (by stem), as the name's
/// own era names them.
/// The faces of a body that are the stored name's face or pieces of it (by stem). A name saved
/// before format 14 has none.
pub fn faces_with_stem(b: &ModelBody, name: &str) -> Vec<usize> {
let (names, wanted) = match legacy_name(name) {
Some(old) => (legacy_face_names(b), old),
None => (face_names(b), name),
};
let stem = strip_piece(wanted);
let aliases = |i: usize| {
legacy_name(name).is_none()
&& naming::names(b).info.get(i).is_some_and(|x| x.synonyms.iter().chain(&x.aliases).any(|a| strip_piece(a) == stem))
};
if legacy_name(name).is_some() {
return Vec::new();
}
let names = face_names(b);
let stem = strip_piece(name);
let aliases = |i: usize| naming::names(b).info.get(i).is_some_and(|x| x.synonyms.iter().chain(&x.aliases).any(|a| strip_piece(a) == stem));
(0..names.len()).filter(|i| names.get(*i).is_some_and(|n| strip_piece(n) == stem) || aliases(*i)).collect()
}
+9 -49
View File
@@ -42,9 +42,7 @@ pub struct NameCell {
/// What the body's faces came from, when the feature that made it reports it.
lineage: Option<Arc<Lineage>>,
names: Arc<OnceLock<Arc<Names>>>,
/// The names designs saved before format 14 gave its faces (see [`LEGACY_MARK`]).
legacy: Arc<OnceLock<Arc<Vec<String>>>>,
/// The body this one is a moved copy of (its legacy names are that body's).
/// The body this one is a moved copy of (its names are that body's).
copy_of: Option<Arc<ModelBody>>,
/// How deep the chain of features that named it is, for names taken from a saved model
/// (which has no origin): see [`NameCell::depth`].
@@ -57,15 +55,11 @@ impl NameCell {
}
/// Names of a body made by `origin`'s feature, following `lineage` where it is known.
pub(crate) fn traced(origin: Option<Arc<NamingOrigin>>, lineage: Option<Arc<Lineage>>) -> Self {
NameCell { origin, lineage, names: Arc::new(OnceLock::new()), legacy: Arc::new(OnceLock::new()), copy_of: None, saved_depth: None }
NameCell { origin, lineage, names: Arc::new(OnceLock::new()), copy_of: None, saved_depth: None }
}
/// Names already known, taken as they are.
pub fn known(names: Arc<Vec<String>>) -> Self {
let legacy = OnceLock::new();
let _ = legacy.set(names.clone());
let mut cell = Self::shared(Arc::new(Names::given(names)));
cell.legacy = Arc::new(legacy);
cell
Self::shared(Arc::new(Names::given(names)))
}
/// The names of `body` (a moved copy of a body keeps its faces' names, and how sure they are).
///
@@ -102,7 +96,7 @@ impl NameCell {
fn shared(names: Arc<Names>) -> Self {
let cell = OnceLock::new();
let _ = cell.set(names);
NameCell { origin: None, lineage: None, names: Arc::new(cell), legacy: Arc::new(OnceLock::new()), copy_of: None, saved_depth: None }
NameCell { origin: None, lineage: None, names: Arc::new(cell), copy_of: None, saved_depth: None }
}
}
@@ -166,7 +160,7 @@ const MAX_DEPTH: usize = 400;
const SAMPLES: usize = 9;
/// Up to this many sample points per face where a sheet's panel kept in place names the face it is
/// part of (see [`geometric_from`]).
/// part of (see [`geometric_names`]).
const PANEL_SAMPLES: usize = 256;
/// Unit normals of each face's triangles.
@@ -420,35 +414,6 @@ pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
.clone()
}
/// The names builds before format 14 gave a body's faces: from geometry, over the inputs' names
/// as those builds gave them, pieces of a split face numbered along the longest axis of their
/// spread (`#1`, `#2`, …).
pub(crate) fn legacy_face_names(b: &ModelBody) -> Arc<Vec<String>> {
if let Some(source) = &b.names.copy_of {
return legacy_face_names(source);
}
b.names
.legacy
.get_or_init(|| {
let nf = face_count(b);
let names = match b.names.origin.as_ref() {
Some(o) if o.depth < MAX_DEPTH => {
let mut names = geometric_from(b, o, nf, legacy_face_names);
number_pieces(&mut names, &samples(&b.mesh(), nf), '#');
names
}
_ => (0..nf).map(|k| format!("{}:face:{k}", b.name)).collect(),
};
Arc::new(names)
})
.clone()
}
/// The names builds before format 14 gave a body's edges.
pub(crate) fn legacy_edge_names(b: &ModelBody) -> Vec<String> {
edge_names_from(b, &legacy_face_names(b))
}
/// Pairs of faces of a body that share an edge (from its mesh's edges).
pub(crate) fn mesh_adjacency(b: &ModelBody) -> Vec<(usize, usize)> {
crate::history_names::face_pairs(b.mesh().edge_faces.iter().map(|fs| fs.iter().map(|f| *f as usize)))
@@ -458,17 +423,12 @@ pub(crate) fn mesh_adjacency(b: &ModelBody) -> Vec<(usize, usize)> {
/// geometry: where each face's sample points lie on the input's faces. Faces sharing a name are
/// pieces of one, told apart afterwards.
pub(crate) fn geometric_names(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
geometric_from(b, o, nf, face_names)
}
/// [`geometric_names`], the input bodies' faces named by `names_of`.
fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&ModelBody) -> Arc<Vec<String>>) -> Vec<String> {
// A rigid Move changes coordinates, not topology or face ownership. Geometric
// overlap with the unmoved input cannot recover the names at the new position.
if matches!(o.feature.kind, FeatureKind::Move { .. })
&& let Some(input) = o.before.body(&b.name)
{
return names_of(input).as_ref().clone();
return face_names(input).as_ref().clone();
}
let m = b.mesh();
let size = b.body.size().max(1e-6);
@@ -505,7 +465,7 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
.take(64)
.map(|x| {
let n = face_count(x);
(x, FaceIndex::new(&x.mesh(), n), names_of(x))
(x, FaceIndex::new(&x.mesh(), n), face_names(x))
})
.collect();
let fid = o.feature.id;
@@ -520,7 +480,7 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
.iter()
.filter(|x| !x.body.is_mesh())
.take(64)
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), names_of(x)))
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), face_names(x)))
.collect();
let mut best: Option<(usize, usize, Vec<Option<String>>)> = None;
for (k, mat) in o.mats.iter().enumerate() {
@@ -1139,7 +1099,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.verified, a.on_point, a.legacy), (b.verified, b.on_point, b.legacy));
assert_eq!((a.verified, a.on_point), (b.verified, b.on_point));
}
#[test]
@@ -120,8 +120,8 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam
let anchored =
solidcraft_doc::Feature { shell_piece_order: true, kernel_piece_order: true, touching: solidcraft_doc::Touching::OneBody, ..anchored };
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
// Names saved before format 14 keep naming the face they named, marked so they are found as
// the build that saved them found them.
// Names saved before format 14 open with the legacy mark; the sketch is held by its anchor,
// not by its name.
let mark = |name: &Option<String>| name.as_ref().map(|n| format!("{}{n}", solidcraft_doc::naming::LEGACY_MARK));
let mut saved_plane = saved_plane;
if let solidcraft_doc::PlaneRef::Face { name, .. } = &mut saved_plane {
@@ -175,7 +175,7 @@ fn certified_side_refuses_reused_body_name_from_a_new_producer() {
}
#[test]
fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
fn legacy_v9_annulus_outer_side_emboss_asks_for_its_face_again() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
@@ -200,19 +200,20 @@ fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
legacy["format"] = json!("solidcraft/9");
// What a version 9 design stored for that pick: the centroid-family name of the outer wall
// piece holding the point. It keeps finding that piece, as it did when saved.
// piece holding the point, worked out from geometry. It names no face today (the outer wall
// is one face, named after its own curve): the emboss fails and asks for its face again,
// and the sleeve is built without it.
for feature in legacy["features"].as_array_mut().unwrap() {
if feature["id"] == json!(feature_id) {
feature["face_names"] = json!(["F2:side:inner#4"]);
}
}
let reopened = Session::new(solidcraft_doc::Document::from_json(&legacy.to_string()).unwrap());
assert!(reopened.model.result(feature_id).unwrap().error.is_none());
assert!(
(reopened.model.state().body("Sleeve").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-4)).unwrap().measure().volume - volume)
.abs()
< 1e-8
);
let error = reopened.model.result(feature_id).unwrap().error.clone().unwrap();
assert_eq!(solidcraft_doc::reselect_parts(&error), Some(("Raised Badge", "?F2:side:inner#4")), "{error}");
let sleeve = reopened.model.state().body("Sleeve").unwrap().body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-4)).unwrap().measure().volume;
assert!((sleeve - std::f64::consts::PI * 380.).abs() < 0.2, "volume {sleeve}");
assert!(reopened.model.results.iter().filter(|r| r.id != feature_id).all(|r| r.error.is_none()), "{:?}", reopened.model.results);
}
#[test]
+16 -23
View File
@@ -2785,7 +2785,9 @@ mod naming {
model.evaluate(&reopened);
assert_eq!(model.state().sketch(reopened.find_feature("Top").unwrap().id).unwrap().plane.origin.z, 10.);
assert!((model.state().bodies[0].mesh().bounds().max.z - 15.).abs() < 1e-6);
// A historical format-10 attachment uses its saved generic face name and no strong anchor.
// A format-10 attachment has only the face name that build worked out from geometry, and
// no strong anchor: that name finds no face today. The sketch fails, asking for its face
// again, and so does the peg on it; the block builds.
let mut old = (*s.doc).clone();
let solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor, name, .. }, sketch } =
&mut old.features.iter_mut().find(|f| f.name == "Top").unwrap().kind
@@ -2793,6 +2795,7 @@ mod naming {
panic!("fixture must contain a face sketch");
};
*anchor = None;
let saved = name.clone().unwrap();
for link in &mut sketch.links {
if link.auto_face {
link.auto_face = false;
@@ -2800,30 +2803,20 @@ mod naming {
}
}
old.format = "solidcraft/10".into();
let mut legacy = Session::new(solidcraft_doc::Document::from_json(&old.to_json()).unwrap());
assert_eq!(attached(&legacy), Some(10.));
assert!((top(&mut legacy) - 15.).abs() < 1e-6);
let legacy = Session::new(solidcraft_doc::Document::from_json(&old.to_json()).unwrap());
let error = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.clone();
let reference = format!("{}{saved}", solidcraft_doc::naming::LEGACY_MARK);
assert_eq!(error(&legacy, "Top").as_deref().and_then(solidcraft_doc::reselect_parts), Some(("Top", reference.as_str())));
assert!(error(&legacy, "Peg").is_some());
assert!(error(&legacy, "Block").is_none());
// An upstream join that trims the certified face: the attachment stays on what is left
// of it and never follows the substitute roof (see face_tests for a face covered whole).
for target in [&mut s, &mut legacy] {
run(target, "timeline.roll_to", json!({"feature": "Block"}));
run(target, "sketch.create", json!({"plane": "XY", "name": "B"}));
run(target, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]}));
run(target, "sketch.finish", json!({}));
run(target, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"}));
run(target, "timeline.roll_to", json!({}));
}
assert_eq!(attached(&legacy), Some(10.), "historical weak naming retains its saved geometry behavior");
assert!((top(&mut legacy) - 20.).abs() < 1e-6);
let legacy_reopened = Session::new(solidcraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap());
assert_eq!(attached(&legacy_reopened), Some(10.));
assert!((legacy_reopened.model.state().bodies[0].mesh().bounds().max.z - 20.).abs() < 1e-6);
let solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor, .. }, .. } =
&legacy_reopened.doc.find_feature("Top").unwrap().kind
else {
panic!("saved historical face reference must remain present");
};
assert!(anchor.is_none(), "opening old face references must not infer a strict attachment");
run(&mut s, "timeline.roll_to", json!({"feature": "Block"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"}));
run(&mut s, "timeline.roll_to", json!({}));
let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some();
assert!(!failed(&s, "Top"));
assert!(!failed(&s, "Peg"));
+3 -3
View File
@@ -130,9 +130,9 @@ the blend records later features read). States share what they share: a body tha
later features pass through unchanged, its face names and a solved sketch are each
recorded once. Face names are kept as worked out (saving works out any not asked
for yet), so a body taken from the file is named exactly as when it was saved.
A design whose stored names predate format 14 (marked `?`) finds them through
names worked out from geometry on demand, which the cache does not keep: it saves
without a model.
A design whose stored names predate format 14 (marked `?`) saves without a model:
those names find no face or edge, and the features holding them ask for their
references to be picked again.
Both entries are Deflate-compressed (level one) under either compression policy: the
encoded bodies shrink about fourfold (an imported assembly) to tenfold (modelled parts),