doc: a face its feature made from several roles has each as a name

A side drawn in collinear sketch pieces is one face the extrude made from each
piece (#1788): its first role is its name, and the others were kept as aliases,
like the names of faces merged into it by a later join. They are names of that
very face, not of faces merged into it: the face keeps them as synonyms, which
a stored name matches as exactly as the face's name (a piece of it, once it is
split), through copies and later features, and the face whose name a merge
keeps keeps its synonyms too. An extrude-side certificate made on one of the
pieces finds the joined face the same way, and the saved model records the
synonyms (model cache format 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 12:12:52 -04:00
committed by jyviko
parent 1b08d4b8b5
commit 4a652ba4a1
10 changed files with 104 additions and 44 deletions
+7 -2
View File
@@ -842,8 +842,13 @@ impl Document {
};
let mut indices: Vec<usize> = names.faces.iter().enumerate().filter(|(_, face)| piece_of(face)).map(|(index, _)| index).collect();
if indices.is_empty() {
indices =
names.info.iter().enumerate().filter(|(_, info)| info.aliases.iter().any(|alias| alias == name)).map(|(index, _)| index).collect();
indices = names
.info
.iter()
.enumerate()
.filter(|(_, info)| info.synonyms.iter().chain(&info.aliases).any(|other| other == name))
.map(|(index, _)| index)
.collect();
}
if indices.is_empty() {
return Err(DocError::Unknown(format!("face `{name}` on `{}` no longer exists", body.name)));
+3 -3
View File
@@ -279,13 +279,13 @@ impl Document {
let count = body.body.face_sources().map_or(0, |sources| sources.len());
let mut indices: Vec<_> = (0..count).filter(|index| side(*index, true)).collect();
// A side drawn in collinear pieces is one face over all of them (#1788): a certificate
// made when the pieces were faces of their own finds it by the piece's name, which
// the joined face answers to.
// made when the pieces were faces of their own finds it by the piece's name, which is
// a name of the joined face too (one of its synonyms).
if indices.is_empty() {
let names = crate::naming::names(body);
indices = (0..count)
.filter(|index| {
side(*index, false) && crate::history_names::matches(&names, *index, &target.name) == Some(crate::history_names::Match::Alias)
side(*index, false) && crate::history_names::matches(&names, *index, &target.name) == Some(crate::history_names::Match::Exact)
})
.collect();
}
+58 -27
View File
@@ -12,7 +12,9 @@
//! 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 answers to the others (aliases).
//! 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.
//!
//! 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
@@ -38,8 +40,13 @@ 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).
/// Names of the other faces merged into it (a reference to one of them finds this face, as
/// the face it was merged into).
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).
/// A reference by one of them finds the face as its name does.
pub synonyms: Vec<String>,
/// The stems of the faces around it: what tells it apart from other pieces of its face.
pub bounds: BTreeSet<String>,
/// Whether the name follows exact history (`false`: worked out from geometry, or a piece
@@ -54,7 +61,7 @@ impl Names {
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)], centres: Option<&dyn Fn() -> Vec<Vec3>>) -> Names {
let named = names
.into_iter()
.map(|name| Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None })
.map(|name| Named { name: crate::naming::strip_piece(&name).to_string(), verified: false, ..Named::default() })
.collect();
qualify_at(named, adjacent, centres)
}
@@ -72,10 +79,11 @@ impl Names {
}
/// A face's name before pieces are told apart.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Default)]
struct Named {
name: String,
aliases: Vec<String>,
synonyms: Vec<String>,
verified: bool,
/// What it came from (`None`: not known): faces with one name but different origins (an
/// extrude's two caps both above its sketch plane) are numbered in this order.
@@ -96,7 +104,7 @@ pub(crate) fn derive(b: &ModelBody, o: &NamingOrigin, lineage: &Lineage) -> Opti
let geometric = crate::naming::geometric_names(b, o, named.len());
for (slot, name) in named.iter_mut().zip(geometric) {
if slot.is_none() {
*slot = Some(Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None });
*slot = Some(Named { name: crate::naming::strip_piece(&name).to_string(), verified: false, ..Named::default() });
}
}
}
@@ -161,35 +169,42 @@ impl<'a> OperandNames<'a> {
fn face(&self, face: usize, fid: u64) -> Option<Named> {
let given = |names: &Names| {
let info = names.info.get(face)?;
Some(Named { name: names.faces.get(face)?.clone(), aliases: info.aliases.clone(), verified: info.verified, origins: None })
Some(Named {
name: names.faces.get(face)?.clone(),
aliases: info.aliases.clone(),
synonyms: info.synonyms.clone(),
verified: info.verified,
origins: None,
})
};
match self.operand {
Operand::Body(_) | Operand::Stage(_) => given(&self.names),
Operand::Copy { instance, .. } => given(&self.names).map(|n| Named {
name: format!("{}@F{fid}.{instance}", n.name),
aliases: n.aliases.iter().map(|a| format!("{a}@F{fid}.{instance}")).collect(),
verified: n.verified,
origins: None,
}),
Operand::Inner(_) => given(&self.names).map(|n| Named {
name: format!("F{fid}:inner:{}", n.name),
aliases: Vec::new(),
verified: n.verified,
origins: None,
Operand::Copy { instance, .. } => given(&self.names).map(|n| {
let copied = |names: &[String]| names.iter().map(|a| format!("{a}@F{fid}.{instance}")).collect();
Named {
name: format!("{}@F{fid}.{instance}", n.name),
aliases: copied(&n.aliases),
synonyms: copied(&n.synonyms),
verified: n.verified,
origins: None,
}
}),
Operand::Inner(_) => {
given(&self.names).map(|n| Named { name: format!("F{fid}:inner:{}", n.name), verified: n.verified, ..Named::default() })
}
Operand::Made(roles) => {
let (first, rest) = roles.get(face)?.split_first()?;
Some(Named {
name: format!("F{fid}:{first}"),
aliases: rest.iter().map(|r| format!("F{fid}:{r}")).collect(),
synonyms: rest.iter().map(|r| format!("F{fid}:{r}")).collect(),
verified: true,
origins: None,
..Named::default()
})
}
Operand::CopiedTool { roles, feature, instance } => {
let named = |role: &String| format!("F{feature}:{role}@F{fid}.{instance}");
let (first, rest) = roles.get(face)?.split_first()?;
Some(Named { name: named(first), aliases: rest.iter().map(named).collect(), verified: true, origins: None })
Some(Named { name: named(first), synonyms: rest.iter().map(named).collect(), verified: true, ..Named::default() })
}
}
}
@@ -219,11 +234,11 @@ fn from_origins(face: &FaceProv, operands: &[OperandNames], fid: u64) -> Option<
FaceOrigin::Face { operand, face } => operands.get(operand)?.face(face, fid)?,
FaceOrigin::Edge { operand, edge } => {
let (edge, verified) = operands.get(operand)?.edge(edge)?;
Named { name: format!("F{fid}:blend:{edge}"), aliases: Vec::new(), verified, origins: None }
Named { name: format!("F{fid}:blend:{edge}"), verified, ..Named::default() }
}
FaceOrigin::Offset { operand, face } => {
let source = operands.get(operand)?.face(face, fid)?;
Named { name: format!("F{fid}:inner:{}", source.name), aliases: Vec::new(), verified: source.verified, origins: None }
Named { name: format!("F{fid}:inner:{}", source.name), verified: source.verified, ..Named::default() }
}
// A profile or cap of a body made from nothing: only a built body's own roles name
// these, and those come in as `Made` operands.
@@ -237,7 +252,8 @@ fn from_origins(face: &FaceProv, operands: &[OperandNames], fid: u64) -> Option<
}
/// One name for a face made of faces with these names: the face they are all pieces of, else the
/// first (the target's before a tool's); the others become aliases.
/// first (the target's before a tool's); the others become aliases. The synonyms of the face
/// whose name it takes stay its synonyms.
fn merged(names: Vec<Named>) -> Option<Named> {
let verified = names.iter().all(|n| n.verified);
let first = names.first()?.name.clone();
@@ -249,15 +265,21 @@ fn merged(names: Vec<Named>) -> Option<Named> {
common = crate::naming::parent_piece(c);
}
let name = common_piece(&names).or_else(|| common.map(str::to_string)).unwrap_or(first);
let mut aliases: Vec<String> = Vec::new();
let (mut aliases, mut synonyms): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new());
for n in names {
for alias in std::iter::once(n.name).chain(n.aliases) {
let (kept, merged) = if n.name == name { (n.synonyms, n.aliases) } else { (Vec::new(), [vec![n.name], n.synonyms, n.aliases].concat()) };
for synonym in kept {
if synonym != name && !synonyms.contains(&synonym) {
synonyms.push(synonym);
}
}
for alias in merged {
if alias != name && !aliases.contains(&alias) {
aliases.push(alias);
}
}
}
Some(Named { name, aliases, verified, origins: None })
Some(Named { name, aliases, synonyms, verified, origins: None })
}
/// The name the faces have in common: their stem, the faces their qualifiers share and the
@@ -441,7 +463,12 @@ fn qualify_at(named: Vec<Named>, adjacent: &[(usize, usize)], centres: Option<&d
}
}
}
let info = named.into_iter().zip(bounds).zip(verified).map(|((x, bounds), verified)| FaceInfo { aliases: x.aliases, bounds, verified }).collect();
let info = named
.into_iter()
.zip(bounds)
.zip(verified)
.map(|((x, bounds), verified)| FaceInfo { aliases: x.aliases, synonyms: x.synonyms, bounds, verified })
.collect();
Names { faces: Arc::new(names), info }
}
@@ -526,6 +553,10 @@ pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match>
if current == name {
return Some(Match::Exact);
}
// Another name of this very face; of a piece of it, when the face was split since.
if info.is_some_and(|i| i.synonyms.iter().any(|a| a == name)) {
return Some(if crate::naming::strip_piece(current) == current { Match::Exact } else { Match::Piece });
}
if info.is_some_and(|i| i.aliases.iter().any(|a| a == name)) {
return Some(Match::Alias);
}
+23 -1
View File
@@ -23,7 +23,7 @@ fn piece_names_parse_into_stem_and_bounding_faces() {
}
fn named(names: &[&str]) -> Vec<Named> {
names.iter().map(|n| Named { name: n.to_string(), aliases: Vec::new(), verified: true, origins: None }).collect()
names.iter().map(|n| Named { name: n.to_string(), verified: true, ..Named::default() }).collect()
}
#[test]
@@ -125,6 +125,28 @@ fn a_merged_face_takes_the_shared_stem_or_the_first_name_and_answers_to_the_othe
assert_eq!(matches(&names, 0, "F9:top"), Some(Match::Alias));
}
/// A side drawn in two collinear pieces is one face its feature made from both: each piece's role
/// names that very face, a piece of it once it is split. Joined with another feature's face, it
/// keeps them, and the other face's names are aliases only.
#[test]
fn a_face_made_from_several_roles_is_named_by_each_and_keeps_them_when_merged() {
let side = Named { name: "F2:side:c1".into(), synonyms: vec!["F2:side:c2".into()], verified: true, ..Named::default() };
let names = qualify(vec![side.clone()], &[]);
assert_eq!(names.faces[0], "F2:side:c1");
assert_eq!(matches(&names, 0, "F2:side:c2"), Some(Match::Exact));
let other = Named { name: "F9:side:a".into(), synonyms: vec!["F9:side:b".into()], verified: true, ..Named::default() };
let m = merged(vec![side.clone(), other]).unwrap();
assert_eq!((m.name.as_str(), m.synonyms.as_slice()), ("F2:side:c1", ["F2:side:c2".to_string()].as_slice()));
assert_eq!(m.aliases, vec!["F9:side:a".to_string(), "F9:side:b".to_string()]);
let names = qualify(vec![m], &[]);
assert_eq!(matches(&names, 0, "F2:side:c2"), Some(Match::Exact));
assert_eq!(matches(&names, 0, "F9:side:b"), Some(Match::Alias));
// Split by a cut: each piece is a piece of what either role names.
let names = qualify(vec![side.clone(), side, Named { name: "F4:side:x".into(), ..Named::default() }], &[(0, 2)]);
assert_eq!(names.faces[0], "F2:side:c1#[F4:side:x]");
assert_eq!(matches(&names, 0, "F2:side:c2"), Some(Match::Piece));
}
/// #3330: a linked design's faces are named after the names that design gave them, so what the
/// name holds must mean nothing else in the assembly, while its pieces stay pieces.
#[test]
+3 -3
View File
@@ -122,7 +122,7 @@ impl Writer {
return *i;
}
let names = crate::naming::names(b);
let info: Vec<Json> = names.info.iter().map(|i| json!([i.aliases, i.bounds, i.verified])).collect();
let info: Vec<Json> = names.info.iter().map(|i| json!([i.aliases, i.bounds, i.verified, i.synonyms])).collect();
self.names.push(json!({"faces": *names.faces, "info": info}));
self.names_index.insert(id, self.names.len() - 1);
self.names.len() - 1
@@ -257,12 +257,12 @@ impl SavedModel {
let mut names = Vec::new();
for n in list("names")? {
let faces: Vec<String> = serde_json::from_value(parse(n.get("faces"))?).map_err(|_| bad("saved model: names"))?;
let info: Vec<(Vec<String>, std::collections::BTreeSet<String>, bool)> =
let info: Vec<(Vec<String>, std::collections::BTreeSet<String>, bool, Vec<String>)> =
serde_json::from_value(parse(n.get("info"))?).map_err(|_| bad("saved model: names"))?;
if info.len() != faces.len() {
return Err(bad("saved model: names"));
}
let info = info.into_iter().map(|(aliases, bounds, verified)| FaceInfo { aliases, bounds, verified }).collect();
let info = info.into_iter().map(|(aliases, bounds, verified, synonyms)| FaceInfo { aliases, synonyms, bounds, verified }).collect();
names.push(Arc::new(Names { faces: Arc::new(faces), info }));
}
let mut sketches = Vec::new();
+4 -2
View File
@@ -478,8 +478,10 @@ pub fn faces_with_stem(b: &ModelBody, name: &str) -> Vec<usize> {
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.aliases.iter().any(|a| strip_piece(a) == stem));
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))
};
(0..names.len()).filter(|i| names.get(*i).is_some_and(|n| strip_piece(n) == stem) || aliases(*i)).collect()
}
+2 -2
View File
@@ -323,8 +323,8 @@ pub fn blend_seams(b: &ModelBody) -> Vec<BlendSeam> {
// Per face: the names it answers to, and the edges a blend face replaced (each as
// `<face>|<face>`, without qualifiers or numbers).
let all = |i: usize| -> Vec<&str> {
let aliases = n.info.get(i).map(|f| f.aliases.as_slice()).unwrap_or_default();
n.faces.get(i).map(String::as_str).into_iter().chain(aliases.iter().map(String::as_str)).collect()
let (aliases, synonyms) = n.info.get(i).map(|f| (f.aliases.as_slice(), f.synonyms.as_slice())).unwrap_or_default();
n.faces.get(i).map(String::as_str).into_iter().chain(synonyms.iter().chain(aliases).map(String::as_str)).collect()
};
let replaced: Vec<Vec<(String, String)>> = (0..n.faces.len())
.map(|i| {
+2 -2
View File
@@ -4449,8 +4449,8 @@ fn a_side_drawn_in_two_collinear_pieces_extrudes_into_one_face() {
assert_eq!(run(&mut s, "inspect.measure", json!({}))["bodies"][0]["kernel"]["faces"], 6);
let names: Vec<&str> = f.iter().filter_map(|x| x["name"].as_str()).collect();
assert!(names.contains(&format!("F2:side:{}", pieces[0]).as_str()), "{names:?}");
// The second piece's name answers for the same face, so a reference made when the side was
// two faces (an edge of the second piece) still finds its edge.
// The extrude made the side from both pieces: the second piece's role names that very face
// too, so an edge named after it is that edge.
let st = s.model.state();
let body = st.body("Body1").unwrap();
let edge = format!("F2:side:{}|F2:top", pieces[1]);
+1 -1
View File
@@ -5,7 +5,7 @@
use super::*;
use solidcraft_doc::{ModelState, SavedModel};
const FORMAT: &str = "solidcraft-model-cache/4";
const FORMAT: &str = "solidcraft-model-cache/5";
const PRODUCER: &str = "solidcraft-truck-adapter/4";
const MANIFEST: &str = "cache/model.json";
const PAYLOAD: &str = "cache/bodies.bin";
+1 -1
View File
@@ -147,7 +147,7 @@ SHA-256 of their encoding), and a placed copy of a repeated part (an instance fr
a STEP assembly) records the part and its matrix, so it shows that part's display
again instead of being meshed on its own.
`cache/model.json` records `solidcraft-model-cache/4`, the producer revision
`cache/model.json` records `solidcraft-model-cache/5`, the producer revision
`solidcraft-truck-adapter/4`, the application version, the build (source commit,
CPU architecture and OS), the SHA-256 of the exact `document.json` (whose blob
references pin every STEP, mesh and image payload) and of `cache/bodies.bin`, the