Persistent naming, step 1 (plan/adr/0002): faces are named from their history (a face lying on an input face keeps its name; new faces by role: F<feature>:side:<sketch curve>, :start/:top/:bottom caps, :blend:<edge>, …; split pieces #k along their spread), edges by the faces they separate (pieces ~k); names worked out lazily per body and shared by its clones; fillets and chamfers store edge names and resolve by name first (a split edge takes the piece nearest the picked point with a warning, a vanished one falls back to the point with a warning); model.faces and model.edges report names; tests for resize, suppress, reorder, an upstream split and a removed sketch segment
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
3efe347809
commit
72297047d5
8 files changed
+628
-24
No files matched your search
@@ -1015,6 +1015,9 @@ pub struct Feature {
|
||||
/// is found again when earlier edits move or resize the body.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub edge_refs: Vec<crate::EdgeRef>,
|
||||
/// Persistent names of the picked edges (resolved first; see `naming`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub edge_names: Vec<String>,
|
||||
#[serde(flatten)]
|
||||
pub kind: FeatureKind,
|
||||
}
|
||||
@@ -1275,7 +1278,17 @@ impl Document {
|
||||
Some(n) if !n.trim().is_empty() => n.trim().to_string(),
|
||||
_ => self.unique_name(kind.base_name()),
|
||||
};
|
||||
let f = Feature { id, name, suppressed: false, body_names: Vec::new(), param_names: Vec::new(), component: 0, edge_refs: Vec::new(), kind };
|
||||
let f = Feature {
|
||||
id,
|
||||
name,
|
||||
suppressed: false,
|
||||
body_names: Vec::new(),
|
||||
param_names: Vec::new(),
|
||||
component: 0,
|
||||
edge_refs: Vec::new(),
|
||||
edge_names: Vec::new(),
|
||||
kind,
|
||||
};
|
||||
match self.marker {
|
||||
Some(m) if m < self.features.len() => {
|
||||
self.features.insert(m, f);
|
||||
|
||||
+67
-10
@@ -23,11 +23,13 @@ pub struct ModelBody {
|
||||
/// Identity of this body's content for the evaluation cache: the key of the evaluation
|
||||
/// that produced it (kept while later features pass it through unchanged).
|
||||
key: u64,
|
||||
/// Persistent face names (worked out on first use).
|
||||
pub(crate) names: crate::naming::NameCell,
|
||||
}
|
||||
|
||||
impl ModelBody {
|
||||
pub fn new(name: String, body: Body, feature: u64) -> Self {
|
||||
ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0 }
|
||||
ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default() }
|
||||
}
|
||||
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
|
||||
pub fn mesh(&self) -> Arc<Mesh> {
|
||||
@@ -216,14 +218,24 @@ impl ModelState {
|
||||
|
||||
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
|
||||
/// made or changed, and sketches it made, take that key; the rest keep theirs.
|
||||
fn stamp(&mut self, before: &ModelState, feature: u64, key: u64) {
|
||||
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64) {
|
||||
let feature = f.id;
|
||||
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
|
||||
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth });
|
||||
for b in &mut self.bodies {
|
||||
let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body)).map(|x| x.key);
|
||||
b.key = kept.unwrap_or_else(|| {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(key, &b.name).hash(&mut h);
|
||||
h.finish()
|
||||
});
|
||||
let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body));
|
||||
match kept {
|
||||
Some(x) => {
|
||||
b.key = x.key;
|
||||
b.names = x.names.clone();
|
||||
}
|
||||
None => {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(key, &b.name).hash(&mut h);
|
||||
b.key = h.finish();
|
||||
b.names = crate::naming::NameCell::new(Some(origin.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.sketches.iter().any(|s| s.feature == feature) {
|
||||
self.sketch_keys.insert(feature, key);
|
||||
@@ -516,7 +528,7 @@ impl Model {
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let error = match r {
|
||||
Ok(()) => {
|
||||
next.stamp(&state, f.id, key);
|
||||
next.stamp(&state, f, key);
|
||||
state = Arc::new(next);
|
||||
None
|
||||
}
|
||||
@@ -902,6 +914,14 @@ pub fn edge_refs(st: &ModelState, body: &Option<String>, pts: &[Vec3]) -> Vec<Ed
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Persistent names of the edges picked at `pts` on the body a blend would go on.
|
||||
pub fn edge_names_for(st: &ModelState, body: &Option<String>, pts: &[Vec3]) -> Vec<String> {
|
||||
match blend_target(st, body, pts).ok().and_then(|i| st.bodies.get(i)) {
|
||||
Some(b) => crate::naming::edge_names_at(b, pts),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge reference points re-found on the body: points on an edge stay; a point that moved off
|
||||
/// (an upstream edit) is carried with the body (its place in the body's bounding box then and
|
||||
/// now) to an edge running the same way; failing that it goes to the nearest edge with a
|
||||
@@ -1919,7 +1939,44 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let r = val(vals, radius, Kind::Length)?;
|
||||
let ti = blend_target(st, body, edges)?;
|
||||
let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) };
|
||||
let edges = resolve_edges(&mb.body, edges, &f.edge_refs, warning)?;
|
||||
let edges = if f.edge_names.iter().any(|n| !n.is_empty()) {
|
||||
// By name first; a name that is gone falls back to the point, with a warning.
|
||||
let (mut out, mut notes) = (Vec::new(), Vec::new());
|
||||
for (i, p) in edges.iter().enumerate() {
|
||||
match f.edge_names.get(i).filter(|n| !n.is_empty()).map(|n| (n, crate::naming::find_edge(mb, n, *p))) {
|
||||
Some((_, Ok((q, false)))) => out.push(q),
|
||||
Some((n, Ok((q, true)))) => {
|
||||
notes.push(format!("edge `{n}` was split; using the piece nearest the picked point"));
|
||||
out.push(q);
|
||||
}
|
||||
Some((_, Err(_))) | None => {
|
||||
let mut w = None;
|
||||
let refs: Vec<EdgeRef> = f.edge_refs.get(i).cloned().into_iter().collect();
|
||||
if let Ok(q) = resolve_edges(&mb.body, &[*p], &refs, &mut w) {
|
||||
out.extend(q);
|
||||
notes.push(match f.edge_names.get(i) {
|
||||
Some(n) if !n.is_empty() => format!("edge `{n}` no longer exists; using the edge at the picked point"),
|
||||
_ => "an edge reference without a name was found by its point".into(),
|
||||
});
|
||||
} else {
|
||||
notes.push(format!(
|
||||
"edge `{}` no longer exists and was skipped",
|
||||
f.edge_names.get(i).map(String::as_str).unwrap_or("?")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(DocError::Invalid("none of the selected edges exist any more".into()));
|
||||
}
|
||||
if !notes.is_empty() {
|
||||
*warning = Some(notes.join("; "));
|
||||
}
|
||||
out
|
||||
} else {
|
||||
resolve_edges(&mb.body, edges, &f.edge_refs, warning)?
|
||||
};
|
||||
let nb = if matches!(f.kind, FeatureKind::Fillet { .. }) {
|
||||
kernel::fillet(&mb.body, &edges, r)?
|
||||
} else {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod expr;
|
||||
pub mod format;
|
||||
pub mod joints;
|
||||
pub mod motion;
|
||||
pub mod naming;
|
||||
mod params;
|
||||
pub mod plastic;
|
||||
pub mod project;
|
||||
@@ -26,7 +27,8 @@ pub mod sheet;
|
||||
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid};
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_refs, parse_metric_thread, pattern_matrices, world_state,
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
|
||||
world_state,
|
||||
};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Persistent names of faces and edges (plan/adr/0002-persistent-naming.md).
|
||||
//!
|
||||
//! Every face of an evaluated body is named after how it was made: a face that lies on a face
|
||||
//! of the feature's input keeps that face's name; a face new to the feature is named by its role
|
||||
//! (`F<feature>:side:<sketch curve>`, `F<feature>:start`/`end`, `F<feature>:blend:<k>`, …).
|
||||
//! Pieces of a split face get `#1`, `#2`, … along the longest axis. Edges are named by the faces
|
||||
//! they separate. Names are worked out lazily, on first request, from the body, the feature
|
||||
//! that made it and the model before that feature.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solvecraft_geom::{Mesh, Seg2, Vec2, Vec3};
|
||||
|
||||
use crate::{Feature, FeatureKind, ModelBody, ModelState};
|
||||
|
||||
/// How a body came to be: the feature and the model just before it.
|
||||
#[derive(Debug)]
|
||||
pub struct NamingOrigin {
|
||||
pub feature: Feature,
|
||||
pub before: Arc<ModelState>,
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
/// The lazily computed names of a body, shared by its clones.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NameCell {
|
||||
pub origin: Option<Arc<NamingOrigin>>,
|
||||
names: Arc<OnceLock<Arc<Vec<String>>>>,
|
||||
}
|
||||
|
||||
impl NameCell {
|
||||
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
|
||||
NameCell { origin, names: Arc::new(OnceLock::new()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Deeper chains than this are named by position only (bounded work and stack).
|
||||
const MAX_DEPTH: usize = 400;
|
||||
|
||||
/// Up to this many sample points per face.
|
||||
const SAMPLES: usize = 9;
|
||||
|
||||
/// Unit normals of each face's triangles.
|
||||
fn normals(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
let mut per: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if let (Some(v), Some([a, b, c])) = (per.get_mut(*f as usize), m.tri(t))
|
||||
&& let Some(n) = (b - a).cross(c - a).normalized()
|
||||
{
|
||||
v.push(n);
|
||||
}
|
||||
}
|
||||
per
|
||||
}
|
||||
|
||||
/// Triangle centroids spread over each face: face → points.
|
||||
fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
let mut per: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if let (Some(v), Some([a, b, c])) = (per.get_mut(*f as usize), m.tri(t)) {
|
||||
v.push((a + b + c) * (1.0 / 3.0));
|
||||
}
|
||||
}
|
||||
per.into_iter()
|
||||
.map(|v| {
|
||||
if v.len() <= SAMPLES {
|
||||
return v;
|
||||
}
|
||||
let step = v.len() as f64 / SAMPLES as f64;
|
||||
(0..SAMPLES).filter_map(|i| v.get((i as f64 * step) as usize).copied()).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
let (ab, ac, ap) = (b - a, c - a, p - a);
|
||||
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
|
||||
if d1 <= 0.0 && d2 <= 0.0 {
|
||||
return a;
|
||||
}
|
||||
let bp = p - b;
|
||||
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
|
||||
if d3 >= 0.0 && d4 <= d3 {
|
||||
return b;
|
||||
}
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
return a + ab * (d1 / (d1 - d3));
|
||||
}
|
||||
let cp = p - c;
|
||||
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
|
||||
if d6 >= 0.0 && d5 <= d6 {
|
||||
return c;
|
||||
}
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
return a + ac * (d2 / (d2 - d6));
|
||||
}
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
|
||||
}
|
||||
let den = va + vb + vc;
|
||||
if den.abs() < 1e-300 {
|
||||
return a;
|
||||
}
|
||||
a + ab * (vb / den) + ac * (vc / den)
|
||||
}
|
||||
|
||||
/// A body's faces as triangle lists with bounding boxes, for "does this point lie on face f".
|
||||
struct FaceIndex {
|
||||
faces: Vec<(Vec3, Vec3, Vec<[Vec3; 3]>)>,
|
||||
}
|
||||
|
||||
impl FaceIndex {
|
||||
fn new(m: &Mesh, nf: usize) -> FaceIndex {
|
||||
let mut faces: Vec<(Vec3, Vec3, Vec<[Vec3; 3]>)> =
|
||||
vec![(Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN), Vec::new()); nf];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if let (Some(slot), Some(tri)) = (faces.get_mut(*f as usize), m.tri(t)) {
|
||||
for p in tri {
|
||||
slot.0 = Vec3::new(slot.0.x.min(p.x), slot.0.y.min(p.y), slot.0.z.min(p.z));
|
||||
slot.1 = Vec3::new(slot.1.x.max(p.x), slot.1.y.max(p.y), slot.1.z.max(p.z));
|
||||
}
|
||||
slot.2.push(tri);
|
||||
}
|
||||
}
|
||||
FaceIndex { faces }
|
||||
}
|
||||
|
||||
/// The face a point lies on (within `tol`).
|
||||
fn face_at(&self, p: Vec3, tol: f64) -> Option<usize> {
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
for (i, (lo, hi, tris)) in self.faces.iter().enumerate() {
|
||||
if p.x < lo.x - tol || p.y < lo.y - tol || p.z < lo.z - tol || p.x > hi.x + tol || p.y > hi.y + tol || p.z > hi.z + tol {
|
||||
continue;
|
||||
}
|
||||
let d = tris.iter().map(|[a, b, c]| closest(p, *a, *b, *c).dist(p)).fold(f64::MAX, f64::min);
|
||||
if d <= tol && best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, i));
|
||||
}
|
||||
}
|
||||
best.map(|(_, i)| i)
|
||||
}
|
||||
}
|
||||
|
||||
fn face_count(b: &ModelBody) -> usize {
|
||||
b.mesh().tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Names of a body's faces (by B-rep face index).
|
||||
pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
|
||||
b.names
|
||||
.names
|
||||
.get_or_init(|| {
|
||||
let nf = face_count(b);
|
||||
let names = match b.names.origin.as_ref() {
|
||||
Some(o) if o.depth < MAX_DEPTH => derive(b, o, nf),
|
||||
_ => (0..nf).map(|k| format!("{}:face:{k}", b.name)).collect(),
|
||||
};
|
||||
Arc::new(names)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Work out a body's face names from the feature that made it and the model before it.
|
||||
fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
let m = b.mesh();
|
||||
let size = b.body.size().max(1e-6);
|
||||
let tol = (size * 1e-4).max(1e-6);
|
||||
let pts = samples(&m, nf);
|
||||
let bb = m.bounds();
|
||||
// Inputs that overlap this body.
|
||||
let overlaps = |x: &ModelBody| {
|
||||
let xb = x.mesh().bounds();
|
||||
!(xb.max.x < bb.min.x - tol
|
||||
|| xb.min.x > bb.max.x + tol
|
||||
|| xb.max.y < bb.min.y - tol
|
||||
|| xb.min.y > bb.max.y + tol
|
||||
|| xb.max.z < bb.min.z - tol
|
||||
|| xb.min.z > bb.max.z + tol)
|
||||
};
|
||||
let inputs: Vec<(&ModelBody, FaceIndex, Arc<Vec<String>>)> = o
|
||||
.before
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|x| !x.body.is_mesh() && overlaps(x))
|
||||
.take(64)
|
||||
.map(|x| {
|
||||
let n = face_count(x);
|
||||
(x, FaceIndex::new(&x.mesh(), n), face_names(x))
|
||||
})
|
||||
.collect();
|
||||
let fid = o.feature.id;
|
||||
let ns = normals(&m, nf);
|
||||
let mut names: Vec<String> = Vec::with_capacity(nf);
|
||||
for (fi, ps) in pts.iter().enumerate() {
|
||||
// Vote: the input face most samples lie on, if it holds at least half of them.
|
||||
let mut votes: Vec<(String, usize)> = Vec::new();
|
||||
for p in ps {
|
||||
for (_, idx, nm) in &inputs {
|
||||
if let Some(f) = idx.face_at(*p, tol)
|
||||
&& let Some(n) = nm.get(f)
|
||||
{
|
||||
match votes.iter_mut().find(|(v, _)| v == n) {
|
||||
Some(v) => v.1 += 1,
|
||||
None => votes.push((n.clone(), 1)),
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
votes.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
let inherited = votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1)).map(|(n, _)| strip_piece(n).to_string());
|
||||
let empty = Vec::new();
|
||||
names.push(inherited.unwrap_or_else(|| role_name(&o.feature, &o.before, ps, ns.get(fi).unwrap_or(&empty), fi, fid)));
|
||||
}
|
||||
number_pieces(&mut names, &pts, '#');
|
||||
names
|
||||
}
|
||||
|
||||
/// A face name without its piece number (`#k`).
|
||||
pub fn strip_piece(n: &str) -> &str {
|
||||
strip_mark(n, '#')
|
||||
}
|
||||
|
||||
/// An edge name without piece numbers: its own (`~k`) and its faces' (`#k`), so pieces of a
|
||||
/// split edge, or of an edge between split faces, match.
|
||||
pub fn strip_edge_piece(n: &str) -> String {
|
||||
let n = strip_mark(n, '~');
|
||||
let mut out = String::with_capacity(n.len());
|
||||
let mut it = n.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
if c == '#' && it.peek().is_some_and(|d| d.is_ascii_digit()) {
|
||||
while it.peek().is_some_and(|d| d.is_ascii_digit()) {
|
||||
it.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn strip_mark(n: &str, mark: char) -> &str {
|
||||
match n.rfind(mark) {
|
||||
Some(i) if n[i + 1..].chars().all(|c| c.is_ascii_digit()) && i + 1 < n.len() => &n[..i],
|
||||
_ => n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Faces sharing a name are pieces of one: number them along the longest axis of their spread.
|
||||
fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for i in 0..names.len() {
|
||||
let n = names[i].clone();
|
||||
if seen.contains(&n) {
|
||||
continue;
|
||||
}
|
||||
seen.push(n.clone());
|
||||
let same: Vec<usize> = (0..names.len()).filter(|j| names[*j] == n).collect();
|
||||
if same.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let centre = |j: usize| {
|
||||
let ps = pts.get(j).cloned().unwrap_or_default();
|
||||
ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)
|
||||
};
|
||||
let cs: Vec<(usize, Vec3)> = same.iter().map(|j| (*j, centre(*j))).collect();
|
||||
let (lo, hi) = cs.iter().fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(l, h), (_, c)| {
|
||||
(Vec3::new(l.x.min(c.x), l.y.min(c.y), l.z.min(c.z)), Vec3::new(h.x.max(c.x), h.y.max(c.y), h.z.max(c.z)))
|
||||
});
|
||||
let ext = hi - lo;
|
||||
let key = |c: Vec3| {
|
||||
if ext.x >= ext.y && ext.x >= ext.z {
|
||||
(c.x, c.y, c.z)
|
||||
} else if ext.y >= ext.z {
|
||||
(c.y, c.z, c.x)
|
||||
} else {
|
||||
(c.z, c.x, c.y)
|
||||
}
|
||||
};
|
||||
let mut order = cs.clone();
|
||||
order.sort_by(|a, b| key(a.1).partial_cmp(&key(b.1)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
for (k, (j, _)) in order.iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(*j) {
|
||||
*slot = format!("{n}{mark}{}", k + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of a face new to `f`.
|
||||
fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usize, fid: u64) -> String {
|
||||
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, .. } => {
|
||||
let Some(ss) = before.sketch(*sketch) else { return format!("F{fid}:face:{fi}") };
|
||||
let n = ss.plane.normal();
|
||||
// A face across the extrusion is a cap; others sweep a sketch curve.
|
||||
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(n).len() < 1e-6);
|
||||
if flat {
|
||||
// On the sketch plane, above it or below it.
|
||||
let h = (c - ss.plane.origin).dot(n);
|
||||
let tol = 1e-6 * (1.0 + h.abs());
|
||||
return format!(
|
||||
"F{fid}:{}",
|
||||
if h.abs() <= tol {
|
||||
"start"
|
||||
} else if h > 0.0 {
|
||||
"top"
|
||||
} else {
|
||||
"bottom"
|
||||
}
|
||||
);
|
||||
}
|
||||
let q = ss.plane.to_local(c);
|
||||
nearest_curve(ss, Vec2::new(q.x, q.y)).map(|id| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
|
||||
}
|
||||
FeatureKind::Fillet { edges, .. } | FeatureKind::Chamfer { edges, .. } => {
|
||||
let k = edges.iter().enumerate().min_by(|a, b| a.1.dist(c).total_cmp(&b.1.dist(c))).map(|(k, _)| k).unwrap_or(0);
|
||||
match f.edge_names.get(k) {
|
||||
Some(e) => format!("F{fid}:blend:{e}"),
|
||||
None => format!("F{fid}:blend:{k}"),
|
||||
}
|
||||
}
|
||||
_ => format!("F{fid}:face:{fi}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sketch curve (id) nearest a point in the sketch plane.
|
||||
fn nearest_curve(ss: &crate::SolvedSketch, q: Vec2) -> Option<String> {
|
||||
let mut best: Option<(f64, String)> = None;
|
||||
for (ci, c) in ss.sketch.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
for s in ss.sketch.segs(ci) {
|
||||
let d = seg_dist(&s, q);
|
||||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||||
best = Some((d, c.id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, id)| id)
|
||||
}
|
||||
|
||||
fn seg_dist(s: &Seg2, q: Vec2) -> f64 {
|
||||
let pts = s.polyline(1e-3);
|
||||
pts.windows(2)
|
||||
.map(|w| {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let d = b - a;
|
||||
let t = if d.len2() > 0.0 { ((q - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(a + d * t).dist(q)
|
||||
})
|
||||
.fold(f64::MAX, f64::min)
|
||||
}
|
||||
|
||||
/// Names of a body's edges (by mesh edge index): `<face A>|<face B>`, numbered when repeated.
|
||||
pub fn edge_names(b: &ModelBody) -> Vec<String> {
|
||||
let m = b.mesh();
|
||||
let faces = face_names(b);
|
||||
let mut names: Vec<String> = m
|
||||
.edge_faces
|
||||
.iter()
|
||||
.map(|fs| {
|
||||
let mut n: Vec<&str> = fs.iter().filter_map(|f| faces.get(*f as usize).map(String::as_str)).collect();
|
||||
n.sort();
|
||||
n.join("|")
|
||||
})
|
||||
.collect();
|
||||
let pts: Vec<Vec<Vec3>> = m.edges.clone();
|
||||
number_pieces(&mut names, &pts, '~');
|
||||
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> {
|
||||
let m = b.mesh();
|
||||
let names = edge_names(b);
|
||||
let mid = |i: usize| -> Option<Vec3> {
|
||||
let e = m.edges.get(i)?;
|
||||
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in e.windows(2) {
|
||||
let l = w[0].dist(w[1]);
|
||||
if acc + l >= total / 2.0 {
|
||||
let t = if l > 0.0 { (total / 2.0 - acc) / l } else { 0.0 };
|
||||
return Some(w[0] + (w[1] - w[0]) * t);
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
e.first().copied()
|
||||
};
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
// Keep the picked point when it is on that edge.
|
||||
let on = m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < (b.body.size() * 2e-3).max(1e-3)));
|
||||
return mid(i).map(|p| (if on { near } else { p }, false)).ok_or_else(|| "edge".into());
|
||||
}
|
||||
let stem = strip_edge_piece(name);
|
||||
let cands: Vec<usize> = (0..names.len()).filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem)).collect();
|
||||
cands
|
||||
.iter()
|
||||
.filter_map(|i| mid(*i))
|
||||
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
|
||||
.map(|p| (p, true))
|
||||
.ok_or_else(|| format!("edge `{name}` no longer exists"))
|
||||
}
|
||||
|
||||
/// Names of the edges nearest each point on a body (for storing references).
|
||||
pub fn edge_names_at(b: &ModelBody, pts: &[Vec3]) -> Vec<String> {
|
||||
let m = b.mesh();
|
||||
let names = edge_names(b);
|
||||
pts.iter()
|
||||
.map(|p| {
|
||||
m.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min), i))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.and_then(|(_, i)| names.get(i).cloned())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -189,7 +189,9 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
) = (&nf.kind, &f.kind)
|
||||
&& a != b
|
||||
{
|
||||
nf.edge_refs = solvecraft_doc::edge_refs(&s.model.state_before(id), body, a);
|
||||
let before = s.model.state_before(id);
|
||||
nf.edge_refs = solvecraft_doc::edge_refs(&before, body, a);
|
||||
nf.edge_names = solvecraft_doc::edge_names_for(&before, body, a);
|
||||
}
|
||||
if let Some(slot) = s.doc_mut().feature_mut(id) {
|
||||
*slot = nf;
|
||||
|
||||
@@ -244,17 +244,17 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
let mut kind = kind;
|
||||
super::component::to_active_frame(s, &mut kind);
|
||||
// Blends remember where their edges sat, to find them again after upstream edits.
|
||||
let refs = match &kind {
|
||||
let (refs, names) = match &kind {
|
||||
FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => {
|
||||
solvecraft_doc::edge_refs(&s.model.state(), body, edges)
|
||||
let st = s.model.state();
|
||||
(solvecraft_doc::edge_refs(&st, body, edges), solvecraft_doc::edge_names_for(&st, body, edges))
|
||||
}
|
||||
_ => Vec::new(),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let id = s.doc_mut().add_feature(kind, name)?;
|
||||
if !refs.is_empty()
|
||||
&& let Some(f) = s.doc_mut().feature_mut(id)
|
||||
{
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.edge_refs = refs;
|
||||
f.edge_names = names;
|
||||
}
|
||||
s.doc_mut().name_feature_inputs(id);
|
||||
let comp = s.active_component;
|
||||
|
||||
@@ -228,11 +228,12 @@ fn model_edges(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.world_state();
|
||||
let b = body_of(&st, p, "model.edges")?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let names = solvecraft_doc::naming::edge_names(b);
|
||||
let edges: Vec<Value> = b
|
||||
.body
|
||||
.edges(tol)?
|
||||
.iter()
|
||||
.map(|e| json!({"index": e.index, "mid": e.mid, "length": e.length, "start": e.points.first(), "end": e.points.last()}))
|
||||
.map(|e| json!({"index": e.index, "name": names.get(e.index), "mid": e.mid, "length": e.length, "start": e.points.first(), "end": e.points.last()}))
|
||||
.collect();
|
||||
Ok(json!({"body": b.name, "edges": edges}))
|
||||
}
|
||||
@@ -240,12 +241,13 @@ fn model_edges(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.world_state();
|
||||
let b = body_of(&st, p, "model.faces")?;
|
||||
let names = solvecraft_doc::naming::face_names(b);
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let faces: Vec<Value> = b
|
||||
.body
|
||||
.faces(tol)?
|
||||
.iter()
|
||||
.map(|f| json!({"index": f.index, "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal}))
|
||||
.map(|f| json!({"index": f.index, "name": names.get(f.index), "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal}))
|
||||
.collect();
|
||||
Ok(json!({"body": b.name, "faces": faces}))
|
||||
}
|
||||
|
||||
+104
-3
@@ -386,12 +386,12 @@ fn timeline_edits_reresolve_references() {
|
||||
let fillet = |l: f64| (9.0 - 9.0 * PI / 4.0) * l;
|
||||
let expect = |l: f64, h: f64| l * 30.0 * h - fillet(l) - PI * 9.0 * h;
|
||||
assert!(rel(volume(&mut s), expect(40.0, 20.0)) < 1e-3);
|
||||
// Taller box: the fillet's edge point is now 5 mm below the edge (re-found, with a warning)
|
||||
// and the hole moves up with the top face.
|
||||
// Taller box: the fillet's edge point is now 5 mm below the edge; the edge is found by its
|
||||
// name (no warning) and the hole moves up with the top face.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "25"}}));
|
||||
assert!(rel(volume(&mut s), expect(40.0, 25.0)) < 1e-3, "{}", volume(&mut s));
|
||||
let round = s.doc.find_feature("Round").map(|f| f.id).unwrap();
|
||||
assert!(s.model.result(round).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("re-found")));
|
||||
assert_eq!(s.model.result(round).and_then(|r| r.warning.clone()), None);
|
||||
// Longer box: the point still lies on the (longer) edge.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60"}}));
|
||||
assert!(rel(volume(&mut s), expect(60.0, 25.0)) < 1e-3, "{}", volume(&mut s));
|
||||
@@ -1166,3 +1166,104 @@ fn fillet_follows_its_edge_after_an_upstream_resize() {
|
||||
let fillet = s.doc.features.last().map(|f| f.id).unwrap_or(0);
|
||||
assert!(s.model.result(fillet).and_then(|r| r.warning.clone()).is_none(), "followed without a warning");
|
||||
}
|
||||
|
||||
mod naming {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::run;
|
||||
use crate::Session;
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn warning(s: &Session, name: &str) -> Option<String> {
|
||||
let id = s.doc.find_feature(name).map(|f| f.id)?;
|
||||
s.model.result(id).and_then(|r| r.warning.clone())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "Extrude", json!({"distance": 10, "name": "Block"}));
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "H"}));
|
||||
run(&mut s, "CircleCenterRadius", json!({"center": [12, 15], "radius": 4}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "Extrude", json!({"sketch": "H", "distance": 10, "operation": "cut", "name": "Hole"}));
|
||||
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[40, 15, 10]], "radius": 2, "name": "Round"}));
|
||||
s
|
||||
}
|
||||
|
||||
const STRIP: f64 = (4.0 - std::f64::consts::PI) * 30.0;
|
||||
|
||||
#[test]
|
||||
fn faces_and_edges_are_named_from_their_history() {
|
||||
let mut s = block();
|
||||
let f = run(&mut s, "model.faces", json!({"body": "Body1"}));
|
||||
let names: Vec<&str> = f["faces"].as_array().into_iter().flatten().filter_map(|x| x["name"].as_str()).collect();
|
||||
for want in ["F2:start", "F2:top", "F2:side:l1", "F2:side:l2", "F2:side:l3", "F2:side:l4"] {
|
||||
assert!(names.contains(&want), "{want} in {names:?}");
|
||||
}
|
||||
assert!(names.iter().any(|n| n.starts_with("F4:side:c1")), "{names:?}");
|
||||
assert!(names.iter().any(|n| n.starts_with("F5:blend:")), "{names:?}");
|
||||
let round = s.doc.find_feature("Round").cloned().unwrap();
|
||||
assert_eq!(round.edge_names, vec!["F2:side:l2|F2:top".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resize_keeps_the_edge() {
|
||||
let mut s = block();
|
||||
let d = s.doc.find_feature("S").and_then(|f| f.param_names.first().cloned()).unwrap_or_else(|| "d1".into());
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "60"}));
|
||||
assert!((volume(&mut s) - (18000.0 - std::f64::consts::PI * 16.0 * 10.0 - STRIP)).abs() < 0.1, "{}", volume(&mut s));
|
||||
assert_eq!(warning(&s, "Round"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppress_and_reorder_upstream_keep_the_edge() {
|
||||
let mut s = block();
|
||||
let v = volume(&mut s);
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Hole", "suppressed": true}));
|
||||
assert!((volume(&mut s) - (12000.0 - STRIP)).abs() < 0.1);
|
||||
assert_eq!(warning(&s, "Round"), None);
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Hole", "suppressed": false}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6);
|
||||
// The hole after the fillet: same part, same edge.
|
||||
run(&mut s, "timeline.reorder", json!({"feature": "Hole", "position": 4}));
|
||||
assert!((volume(&mut s) - v).abs() < 0.1, "{} {v}", volume(&mut s));
|
||||
assert_eq!(warning(&s, "Round"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_edge_warns_and_takes_the_nearest_piece() {
|
||||
let mut s = block();
|
||||
// Upstream of the fillet, a notch through the middle of the filleted edge.
|
||||
run(&mut s, "timeline.rollTo", json!({"feature": "Hole"}));
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "N"}));
|
||||
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [36, 12], "p1": [44, 18]}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "Extrude", json!({"sketch": "N", "distance": 10, "operation": "cut", "name": "Notch"}));
|
||||
run(&mut s, "timeline.rollTo", json!({}));
|
||||
let w = warning(&s, "Round").unwrap_or_default();
|
||||
assert!(w.contains("split"), "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_segment_warns() {
|
||||
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();
|
||||
run(&mut s, "SketchActivate", json!({"sketch": sk}));
|
||||
run(&mut s, "sketch.delete", json!({"entities": ["l2"]}));
|
||||
run(&mut s, "DrawPolyline", json!({"points": [[40, 0], [40, 30]], "ids": ["right"]}));
|
||||
let r: Result<Value, _> = s.execute("SketchStop", &json!({}));
|
||||
assert!(r.is_ok() || r.is_err());
|
||||
let w = warning(&s, "Round").unwrap_or_default();
|
||||
assert!(w.contains("no longer exists"), "{w}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user