Files
OneCAD/crates/doc/src/history_names.rs
T
Iraklis KourtisandClaude Opus 5.5 08cf526fe2 feat(doc): persistent names follow the kernel's exact face history (#136)
Why: every reference a user makes to a face or edge (fillet and chamfer
edges, sketches on faces, shell/draft/offset faces, joints, appearances)
is stored as a name and found again after any upstream edit. Those names
were re-derived by sampling points of each face and voting, and pieces
of a split face were numbered by where their centres lay. After an
upstream edit that splits, merges, mirrors or shrinks a face, a fillet
or sketch could silently re-attach to the wrong face, and nothing told
the user. The kernel now reports exact history for its operations, so
names follow it and fail loudly instead of guessing.

- crates/doc/src/lineage.rs: a feature's kernel histories composed into
  one lineage over nameable operands (bodies before the feature, moved
  copies, shell cavities, tools named by role: sketch curve, cap, box
  side, imported face). Instrumented: extrude/revolve/sweep/loft tools,
  join/cut/intersect, lumps after a cut, fillet/chamfer, shell, draft,
  split face/body, pattern/mirror (bodies and features), combine,
  boundary fill, move/scale, offset/delete face, thread, import.
- crates/doc/src/history_names.rs: names from lineage; split pieces told
  apart by the faces only they touch (F2:top#[F2:side:l4,F4:side:l4]),
  merged faces answer to every name (aliases), lump bodies ordered by
  their faces' names instead of shell order.
- crates/doc/src/name_lookup.rs, piece_names.rs: references resolve by
  exact name, alias or bounding faces, else nearest piece with a
  warning; names worked out from geometry (operations without history)
  are reported when only geometry decided.
- Format 14: names stored by older formats get a legacy mark and are
  found as the build that saved them found them (some geometric names
  name a face history names differently: a tube's outer wall was named
  after its inner circle). New corpus file saved by 9a979cf.
- kernel: Body::lumps carries each piece's history over the whole.
- Adversarial tests: split, merge, mirrored twins, shrink to a sliver,
  moving cut with body pieces, history-less bodies, certified sketch.
- docs/book/src/dev/naming.md describes the real mechanism and why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00

397 lines
17 KiB
Rust

//! Face names from exact history, and how a stored name matches a face.
//!
//! A face's name says what made it (see `naming` for the vocabulary). Where the feature that made
//! a body reports its history (a [`Lineage`]), each face is named after the faces it came from:
//! kept, trimmed or moved faces keep their names, a face made from a profile segment or cap is
//! named by that role, a fillet face by the edge it replaces, a shell's inner wall by the face it
//! offsets. Nothing is sampled. Where a face's origin cannot be named exactly the face is named
//! from geometry, as a body without history is, and marked unverified.
//!
//! Two faces with the same name are pieces of one face (a cut split it). Each piece is told apart
//! by the faces around it that the others do not have: `F2:top#[F2:side:l4,F4:side:l4]` is the
//! 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).
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, OnceLock};
use solvecraft_kernel::history::{FaceOrigin, FaceProv, History};
use crate::ModelBody;
use crate::lineage::{Lineage, Operand};
use crate::naming::NamingOrigin;
/// The names of a body's faces, with what finding them again needs.
#[derive(Debug, Default)]
pub struct Names {
pub faces: Arc<Vec<String>>,
pub(crate) info: Vec<FaceInfo>,
}
/// 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).
pub aliases: 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
/// numbered because the faces around it did not tell it apart).
pub verified: bool,
}
impl Names {
/// Names worked out from geometry: the same names, none of them verified, pieces told apart
/// by the faces around them (`adjacent`: pairs of faces sharing an edge).
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)]) -> Names {
let named = names
.into_iter()
.map(|name| Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None })
.collect();
qualify(named, adjacent)
}
/// Names given as they are (a moved copy keeps its source's), taken as exact.
pub(crate) fn given(names: Arc<Vec<String>>) -> Names {
let info = vec![FaceInfo { verified: true, ..FaceInfo::default() }; names.len()];
Names { faces: names, info }
}
/// Whether face `index`'s name follows exact history.
pub fn verified(&self, index: usize) -> bool {
self.info.get(index).is_some_and(|i| i.verified)
}
}
/// A face's name before pieces are told apart.
#[derive(Clone, Debug)]
struct Named {
name: String,
aliases: 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.
origins: Option<Vec<FaceOrigin>>,
}
/// Names of a body (`b`, made by `o`'s feature) from its lineage; `None` when the lineage does
/// not describe it (then it is named from geometry).
pub(crate) fn derive(b: &ModelBody, o: &NamingOrigin, lineage: &Lineage) -> Option<Names> {
let h = &lineage.history;
if h.faces.len() != b.body.face_count() || h.edges.len() != b.body.edge_count() {
return None;
}
let operands: Vec<OperandNames> = lineage.operands.iter().map(|op| OperandNames::new(op, o)).collect::<Option<_>>()?;
let fid = o.feature.id;
let mut named: Vec<Option<Named>> = h.faces.iter().map(|f| from_origins(f, &operands, fid)).collect();
if named.iter().any(Option::is_none) {
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 });
}
}
}
let named: Vec<Named> = named.into_iter().collect::<Option<_>>()?;
Some(qualify(named, &adjacency(h)))
}
/// Pairs of faces that share an edge, from a history.
fn adjacency(h: &History) -> Vec<(usize, usize)> {
face_pairs(h.edges.iter().map(|e| e.faces.iter().copied()))
}
/// The two faces of each edge (edges given by their faces) that has exactly two.
pub(crate) fn face_pairs<I: Iterator<Item = usize>>(edges: impl Iterator<Item = I>) -> Vec<(usize, usize)> {
edges
.filter_map(|mut faces| match (faces.next(), faces.next(), faces.next()) {
(Some(a), Some(b), None) => Some((a, b)),
_ => None,
})
.collect()
}
/// An operand's faces' names.
struct OperandNames<'a> {
operand: &'a Operand,
/// The model body (or a stand-in for a stage) the operand's names belong to.
body: Option<ModelBody>,
names: Arc<Names>,
/// Its edges' names, worked out on first use (for the faces that blend them).
edges: OnceLock<Vec<String>>,
}
impl<'a> OperandNames<'a> {
fn new(operand: &'a Operand, o: &NamingOrigin) -> Option<OperandNames<'a>> {
match operand {
Operand::Body(name) | Operand::Copy { body: name, .. } | Operand::Inner(name) => {
let body = o.before.body(name)?;
Some(OperandNames { operand, names: crate::naming::names(body), body: Some(body.clone()), edges: OnceLock::new() })
}
Operand::Made(_) | Operand::CopiedTool { .. } => {
Some(OperandNames { operand, body: None, names: Arc::default(), edges: OnceLock::new() })
}
Operand::Stage(stage) => {
let body = &stage.body;
let names = stage
.names
.get_or_init(|| {
Arc::new(derive(body, o, &stage.lineage).unwrap_or_else(|| {
let names = crate::naming::geometric_names(body, o, body.body.face_count());
Names::unverified(names, &crate::naming::mesh_adjacency(body))
}))
})
.clone();
Some(OperandNames { operand, body: Some(body.clone()), names, edges: OnceLock::new() })
}
}
}
/// The name of face `face`.
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 })
};
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::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(),
verified: true,
origins: None,
})
}
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 })
}
}
}
/// The name of edge `edge`, and whether it is exact.
fn edge(&self, edge: usize) -> Option<(String, bool)> {
match self.operand {
Operand::Body(_) | Operand::Stage(_) => {
let body = self.body.as_ref()?;
let names = self.edges.get_or_init(|| crate::naming::edge_names(body));
let faces = body.mesh().edge_faces.get(edge).cloned().unwrap_or_default();
let verified = faces.iter().all(|f| self.names.verified(*f as usize));
Some((names.get(edge)?.clone(), verified))
}
Operand::Copy { .. } | Operand::Inner(_) | Operand::Made(_) | Operand::CopiedTool { .. } => None,
}
}
}
/// A face's name from what it came from; `None` when an origin cannot be named exactly.
fn from_origins(face: &FaceProv, operands: &[OperandNames], fid: u64) -> Option<Named> {
let mut origins: Vec<FaceOrigin> = face.origins.clone();
origins.sort_unstable();
let mut names: Vec<Named> = Vec::with_capacity(origins.len());
for origin in origins.iter().copied() {
let named = match origin {
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 }
}
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 }
}
// 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.
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => return None,
};
if !names.iter().any(|n| n.name == named.name) {
names.push(named);
}
}
merged(names).map(|n| Named { origins: Some(origins), ..n })
}
/// 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.
fn merged(names: Vec<Named>) -> Option<Named> {
let verified = names.iter().all(|n| n.verified);
let first = names.first()?.name.clone();
let mut common = Some(first.as_str());
while let Some(c) = common {
if names.iter().all(|n| descends(&n.name, c)) {
break;
}
common = crate::naming::parent_piece(c);
}
let name = common.map(str::to_string).unwrap_or(first);
let mut aliases: Vec<String> = Vec::new();
for n in names {
for alias in std::iter::once(n.name).chain(n.aliases) {
if alias != name && !aliases.contains(&alias) {
aliases.push(alias);
}
}
}
Some(Named { name, aliases, verified, origins: None })
}
/// Whether `name` is `stem` or a piece of it (at any depth).
pub(crate) fn descends(name: &str, stem: &str) -> bool {
let mut current = Some(name);
while let Some(n) = current {
if n == stem {
return true;
}
current = crate::piece_names::parent_piece(n);
}
false
}
/// Tell pieces apart: faces sharing a name are named by the faces around them that the others
/// lack (`adjacent`: pairs of faces sharing an edge).
fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
let n = named.len();
let mut bounds: Vec<BTreeSet<String>> = vec![BTreeSet::new(); n];
for &(a, b) in adjacent {
let (Some(na), Some(nb)) = (named.get(a), named.get(b)) else { continue };
let (sa, sb) = (crate::naming::strip_piece(&na.name), crate::naming::strip_piece(&nb.name));
if a == b || sa == sb {
continue;
}
if let Some(set) = bounds.get_mut(a) {
set.insert(sb.to_string());
}
if let Some(set) = bounds.get_mut(b) {
set.insert(sa.to_string());
}
}
let mut groups: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
for (i, x) in named.iter().enumerate() {
groups.entry(x.name.as_str()).or_default().push(i);
}
let mut names: Vec<String> = named.iter().map(|x| x.name.clone()).collect();
let mut verified: Vec<bool> = named.iter().map(|x| x.verified).collect();
for (base, members) in groups.iter().filter(|(_, m)| m.len() > 1) {
let empty = BTreeSet::new();
let common: BTreeSet<&String> = members
.iter()
.map(|i| bounds.get(*i).unwrap_or(&empty))
.fold(None::<BTreeSet<&String>>, |acc, set| {
Some(match acc {
None => set.iter().collect(),
Some(acc) => acc.into_iter().filter(|x| set.contains(*x)).collect(),
})
})
.unwrap_or_default();
let own = |i: usize| -> Vec<&String> { bounds.get(i).unwrap_or(&empty).iter().filter(|x| !common.contains(x)).collect() };
let mut by_key: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for &i in members {
let key: Vec<&str> = own(i).into_iter().map(String::as_str).collect();
// Nothing around it of its own: the piece is only numbered (below).
let name = if key.is_empty() { base.to_string() } else { format!("{base}#[{}]", key.join(",")) };
by_key.entry(name).or_default().push(i);
}
for (key, tied) in by_key {
if let [i] = tied.as_slice() {
if let Some(slot) = names.get_mut(*i) {
*slot = key;
}
continue;
}
// The faces around them don't tell these apart: numbered by what they came from when
// that differs (exact), else in the kernel's order (unverified).
let mut tied = tied;
let origins: Vec<Option<&Vec<FaceOrigin>>> = tied.iter().map(|i| named.get(*i).and_then(|x| x.origins.as_ref())).collect();
let distinct = origins.iter().all(Option::is_some) && origins.iter().collect::<BTreeSet<_>>().len() == origins.len();
if distinct {
tied.sort_by_key(|i| named.get(*i).and_then(|x| x.origins.clone()));
}
for (k, i) in tied.iter().enumerate() {
if let Some(slot) = names.get_mut(*i) {
*slot = format!("{key}#{}", k + 1);
}
if let Some(v) = verified.get_mut(*i).filter(|_| !distinct) {
*v = false;
}
}
}
}
// A piece's new name can meet a face that already had it: numbered too, never shared.
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
for name in &names {
*seen.entry(name.clone()).or_default() += 1;
}
let mut counter: BTreeMap<String, usize> = BTreeMap::new();
for (i, name) in names.iter_mut().enumerate() {
if seen.get(name.as_str()).is_some_and(|c| *c > 1) {
let k = counter.entry(name.clone()).or_default();
*k += 1;
*name = format!("{name}#{k}");
if let Some(v) = verified.get_mut(i) {
*v = false;
}
}
}
let info = named.into_iter().zip(bounds).zip(verified).map(|((x, bounds), verified)| FaceInfo { aliases: x.aliases, bounds, verified }).collect();
Names { faces: Arc::new(names), info }
}
/// How a stored face name matches a current face.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Match {
/// Only its root stem is the same: some piece of the same face (the caller picks by point).
Stem,
/// A piece of the named face, or the named piece told apart by the faces around it.
Piece,
/// One of the faces merged into this one.
Alias,
/// The very name.
Exact,
}
/// How the stored name `name` matches face `index` (`None`: not at all).
pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match> {
let current = names.faces.get(index)?;
let info = names.info.get(index);
if current == name {
return Some(Match::Exact);
}
if info.is_some_and(|i| i.aliases.iter().any(|a| a == name)) {
return Some(Match::Alias);
}
// The named face (or piece) was split further: this is one of its pieces.
if descends(current, name) {
return Some(Match::Piece);
}
// The named piece, told apart by the faces around it: they still bound this piece.
if let Some((stem, around)) = crate::piece_names::piece_parts(name)
&& crate::naming::parent_piece(current) == Some(stem)
&& info.is_some_and(|i| around.iter().all(|f| i.bounds.contains(*f)))
{
return Some(Match::Piece);
}
let root = crate::naming::strip_piece(name);
(crate::naming::strip_piece(current) == root).then_some(Match::Stem)
}
#[cfg(test)]
#[path = "history_names_tests.rs"]
mod tests;