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
This commit is contained in:
Iraklis KourtisandClaude Opus 5.5 authored and bherbruck committed 2026-10-12 11:52:53 -04:00
1 parent 2bda050872
commit 08cf526fe2
29 files changed
+2917 -322

No files matched your search

+4 -4
View File
@@ -48,7 +48,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
@@ -183,9 +183,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
references resolve by name first and warn when the entity split or vanished. Kernel-reported
face history (exact through coincident surfaces) is not wired in yet.
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
references resolve by name first, follow splits and merges, and warn when the entity split,
merged or vanished, or was found by geometry only (operations that report no history yet).
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
source certificates with checked target and author occurrence paths. A missing or changed
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
@@ -15,6 +15,7 @@ hostile variants of it never panic.
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the
point is that it was written by the old one. Add new files for new eras instead.
@@ -0,0 +1,144 @@
{
"bodies": [
{
"area_mm2": 4078.7680390666596,
"bbox": {
"max": [
40.0,
30.000000000000014,
12.000000000000002
],
"min": [
0.0,
0.0,
0.0
]
},
"center_of_mass": [
19.25890231218174,
15.000000010054242,
5.04117761026949
],
"density_g_cm3": 1.29,
"edges": 30,
"face_types": {
"cylinder": 1,
"plane": 11
},
"faces": 12,
"kernel": {
"edges": 30,
"faces": 12,
"shells": 1,
"vertices": 20
},
"mass_g": 14.435720327850033,
"material": "Default",
"name": "Body1",
"vertices": 20,
"volume_mm3": 11190.480874302351
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "SlotSketch",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.09895799999999999,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 20.768292000000002,
"name": "Block",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 0.057,
"name": "SlotSketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 136.28875,
"name": "Slot",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 153.58625,
"name": "Round",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 169.174375,
"name": "Bevel",
"rolled_back": false,
"suppressed": false,
"type": "ChamferFeature",
"warning": null
},
{
"error": null,
"id": 7,
"ms": 47.772166,
"name": "Raise",
"rolled_back": false,
"suppressed": false,
"type": "OffsetFacesFeature",
"warning": null
}
],
"total": {
"area_mm2": 4078.7680390666596,
"edges": 30,
"faces": 12,
"mass_g": 14.435720327850033,
"vertices": 20,
"volume_mm3": 11190.480874302351
}
}
@@ -0,0 +1,373 @@
{
"format": "solvecraft/13",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Base",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
40.0,
30.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
0.0,
30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
},
{
"id": "k5",
"type": "coincident",
"p": 0,
"q": 1
}
],
"counters": {
"k": 5,
"l": 4,
"p": 4
}
}
},
{
"id": 2,
"name": "Block",
"suppressed": false,
"param_names": [
"d1"
],
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "SlotSketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
15.0,
-5.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
25.0,
-5.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
25.0,
35.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
15.0,
35.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
}
],
"counters": {
"k": 4,
"l": 4,
"p": 4
}
}
},
{
"id": 4,
"name": "Slot",
"suppressed": false,
"param_names": [
"d2",
"d3"
],
"type": "extrude",
"sketch": 3,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive",
"start_offset": "5"
},
"operation": "cut"
},
{
"id": 5,
"name": "Round",
"suppressed": false,
"param_names": [
"d4"
],
"edge_refs": [
{
"dir": [
0.0,
-1.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
30.0,
10.0
]
}
],
"edge_names": [
"F2:side:l2|F2:top#2"
],
"type": "fillet",
"edges": [
[
40.0,
15.0,
10.0
]
],
"radius": "2"
},
{
"id": 6,
"name": "Bevel",
"suppressed": false,
"param_names": [
"d5"
],
"edge_refs": [
{
"dir": [
0.0,
1.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
30.000000000000007,
10.0
]
}
],
"edge_names": [
"F2:side:l4|F2:top#1"
],
"type": "chamfer",
"edges": [
[
0.0,
15.0,
10.0
]
],
"distance": "1.5"
},
{
"id": 7,
"name": "Raise",
"suppressed": false,
"param_names": [
"d6"
],
"face_names": [
"F2:top#1"
],
"type": "offset_face",
"faces": [
[
7.0,
15.0,
10.0
]
],
"distance": "2"
}
],
"next_id": 8
}
@@ -0,0 +1,111 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "Base"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
0,
0
],
"p1": [
40,
30
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"distance": 10,
"name": "Block"
}
},
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "SlotSketch"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
15,
-5
],
"p1": [
25,
35
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"sketch": "SlotSketch",
"distance": 10,
"start_offset": 5,
"operation": "cut",
"name": "Slot"
}
},
{
"command": "solid.fillet",
"params": {
"edges": [
[
40,
15,
10
]
],
"radius": 2,
"name": "Round"
}
},
{
"command": "solid.chamfer",
"params": {
"edges": [
[
0,
15,
10
]
],
"distance": 1.5,
"name": "Bevel"
}
},
{
"command": "solid.offset_face",
"params": {
"faces": [
[
7,
15,
10
]
],
"distance": 2,
"name": "Raise"
}
}
]
}
@@ -90,6 +90,26 @@ fn old_designs_open_and_evaluate_as_saved() {
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// 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.
#[test]
fn names_saved_before_exact_history_find_the_faces_they_named() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solvecraft");
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);
}
// 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(solvecraft_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]));
}
/// A small deterministic generator (xorshift).
struct Rng(u64);
impl Rng {
+1 -1
View File
@@ -14,7 +14,7 @@ pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anc
body.source_name = raw.source_name.clone();
body.source_transform = crate::mat_mul(&relative, &raw.source_transform);
body.imported = raw.imported.clone();
body.names = crate::naming::NameCell::known(crate::naming::face_names(raw));
body.names = crate::naming::NameCell::same(raw);
let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?;
*slot = body;
Ok(Cow::Owned(output))
+1 -2
View File
@@ -217,8 +217,7 @@ impl Document {
}
// By name (every piece of a split face), else by the point.
if let Some(n) = &f.name {
let stem = crate::naming::strip_piece(n);
let hits: Vec<usize> = (0..names.len()).filter(|i| names.get(*i).is_some_and(|x| crate::naming::strip_piece(x) == stem)).collect();
let hits = crate::naming::faces_with_stem(b, n);
if !hits.is_empty() {
for i in hits {
out.retain(|(j, _)| *j != i);
+1 -1
View File
@@ -46,7 +46,7 @@ pub(crate) fn evaluate(doc: &Document, st: &mut ModelState, bodies: &[String], f
body.source_name = old.source_name.clone();
body.source_transform = crate::mat_mul(&transform, &old.source_transform);
body.imported = old.imported.clone();
body.names = crate::naming::NameCell::known(crate::naming::face_names(old));
body.names = crate::naming::NameCell::same(old);
let slot = next.bodies.iter_mut().find(|b| b.name == *name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?;
*slot = body;
}
+7 -1
View File
@@ -80,6 +80,10 @@ impl FeatureKind {
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> String {
// An older design's name keeps its mark; what follows it is remapped as any name.
if let Some(old) = crate::naming::legacy_name(name) {
return format!("{}{}", crate::naming::LEGACY_MARK, remap_topology(old, ids, bodies));
}
// Imported names belong to the complete canonical body prefix, even when that prefix
// happens to contain text resembling a feature token.
if let Some((body, suffix)) = name.rsplit_once(":face:")
@@ -91,7 +95,9 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
let mut at = 0;
while at < name.len() {
let rest = &name[at..];
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@') | Some(b':') | Some(b'|'))) {
// A feature token starts a name, or one inside it: after `@`, `:` or `|`, or in a
// piece's list of the faces around it (`[`, `,`).
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@' | b':' | b'|' | b'[' | b','))) {
let end = rest.as_bytes().iter().skip(1).take_while(|b| b.is_ascii_digit()).count() + 1;
if end > 1
&& matches!(rest.as_bytes().get(end), Some(b':') | Some(b'.'))
+7 -5
View File
@@ -146,7 +146,7 @@ fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
assert!((after.max.x - before.max.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
assert!((after.min.y - before.min.y).abs() < 1e-8 && (after.min.z - before.min.z).abs() < 1e-8);
let saved = doc.to_json();
assert!(saved.contains("solvecraft/13"));
assert!(saved.contains(&crate::format::format_string(crate::format::FORMAT)));
let loaded = crate::format::read(&saved).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
@@ -176,10 +176,10 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
legacy["format"] = json!("solvecraft/12");
let loaded = crate::format::read(&legacy.to_string()).unwrap();
let mut current: serde_json::Value = serde_json::from_str(&loaded.to_json()).unwrap();
assert_eq!(current["format"], "solvecraft/13");
assert_eq!(current["format"], crate::format::format_string(crate::format::FORMAT));
current["format"] = json!("solvecraft/12");
assert_eq!(legacy, current, "identity migration must preserve every prior field");
legacy["format"] = json!("solvecraft/14");
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&legacy.to_string()).is_err());
}
@@ -253,8 +253,10 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
depth: 1,
mats: vec![],
})));
let former = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
assert_eq!(former, format!("{stem}#1"), "historical actual strip capture records full piece name");
let captured = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
assert_eq!(crate::naming::parent_piece(&captured), Some(stem.as_str()), "actual strip capture records a piece name");
// What the builds before format 14 saved for that piece: its number by centroid order.
let former = format!("{stem}#1");
let mut sketch = solvecraft_sketch::Sketch::new();
let corners = [Vec2::new(0., 0.), Vec2::new(12., 0.), Vec2::new(12., 2.), Vec2::new(0., 2.)];
let geometry: Vec<_> = (0..4).map(|i| solvecraft_sketch::LinkGeom::Line(corners[i], corners[(i + 1) % 4])).collect();
+270 -150
View File
@@ -10,6 +10,7 @@ use solvecraft_kernel::{self as kernel, Body, BoolOp};
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve};
use crate::expr::{self, Kind, Value};
use crate::lineage::Traced;
use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operation, PlaneRef, ProfileSel, Result};
/// A body in the evaluated model.
@@ -31,6 +32,8 @@ pub struct ModelBody {
key: u64,
/// Persistent face names (worked out on first use).
pub(crate) names: crate::naming::NameCell,
/// How the feature being evaluated made or changed it (read when its names are set up).
pub(crate) made: crate::lineage::Made,
/// The component the body belongs to (set after each feature).
pub component: u64,
/// A timeline transfer has converted this body into its evaluated owner's frame.
@@ -50,6 +53,7 @@ impl ModelBody {
mesh: Arc::new(OnceLock::new()),
key: 0,
names: Default::default(),
made: Default::default(),
component: 0,
transferred: false,
imported: None,
@@ -296,6 +300,8 @@ impl ModelState {
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, mats });
// Pieces a cut left are named after their faces, not the kernel's shell order.
crate::lineage::order_lumps(&mut self.bodies, &origin);
for b in &mut self.bodies {
if !matches!(f.kind, FeatureKind::BodyTransfer { .. })
&& let Some(old) = before.bodies.iter().find(|old| old.name == b.name && old.transferred)
@@ -315,7 +321,7 @@ impl ModelState {
(key, &b.name).hash(&mut h);
b.key = h.finish();
if !matches!(f.kind, FeatureKind::BodyTransfer { .. }) {
b.names = crate::naming::NameCell::new(Some(origin.clone()));
b.names = crate::naming::NameCell::traced(Some(origin.clone()), b.lineage());
}
// Faces split on purpose stay split through later features (for export).
if b.body.split_lines().is_none()
@@ -670,6 +676,9 @@ impl Model {
recomputed += 1;
let t0 = now();
let mut next = (*state).clone();
for b in &mut next.bodies {
b.made = crate::lineage::Made::Before;
}
let mut warning = None;
// Face references by name: move each face point onto its named face.
let renamed;
@@ -683,12 +692,12 @@ impl Model {
if n.is_empty() {
continue;
}
match crate::naming::point_on_face(&state, n, *p) {
Some((q, split)) => {
if split {
notes.push(format!("face `{n}` was split; using the piece nearest the picked point"));
match crate::naming::locate_face(&state, n, *p) {
Some(hit) => {
if let Some(note) = hit.note(n) {
notes.push(note);
}
*p = q;
*p = hit.point;
}
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
}
@@ -884,7 +893,7 @@ fn sheet_boolean_unchanged(body: &Body, tools: &[Body], op: Operation) -> Result
fn sheet_boolean_changes(body: &Body, tools: &[Body], op: Operation) -> Result<bool> {
let united;
let tools: &[Body] = if op == Operation::Intersect {
united = intersection_tool_union(tools)?;
united = intersection_tool_union(tools.iter().cloned().map(Traced::unnamed).collect())?.into_iter().map(|t| t.body).collect::<Vec<_>>();
&united
} else {
tools
@@ -900,18 +909,20 @@ fn sheet_boolean_changes(body: &Body, tools: &[Body], op: Operation) -> Result<b
/// Selected profiles form one machining tool, including disconnected tool material.
/// Balanced unions avoid repeatedly rebuilding a growing aggregate boundary.
fn intersection_tool_union(tools: &[Body]) -> Result<Vec<Body>> {
if tools.len() > 1000 || tools.iter().fold(0usize, |n, b| n.saturating_add(b.face_count())) > 16_384 {
fn intersection_tool_union(tools: Vec<Traced>) -> Result<Vec<Traced>> {
if tools.len() > 1000 || tools.iter().fold(0usize, |n, t| n.saturating_add(t.body.face_count())) > 16_384 {
return Err(DocError::Invalid("intersection tools exceed the bounded geometry budget".into()));
}
let mut groups = tools.to_vec();
let mut groups = tools;
while groups.len() > 1 {
let mut next = Vec::new();
let mut iter = groups.into_iter();
while let Some(a) = iter.next() {
match iter.next() {
Some(b) => {
next.push(kernel::boolean(&a, &b, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("intersection tool union is empty".into()))?)
let u = kernel::boolean(&a.body, &b.body, BoolOp::Union)?
.ok_or_else(|| DocError::Invalid("intersection tool union is empty".into()))?;
next.push(Traced::step(u, &[&a, &b]));
}
None => next.push(a),
}
@@ -922,7 +933,12 @@ fn intersection_tool_union(tools: &[Body]) -> Result<Vec<Body>> {
}
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
let tools = if op == Operation::Intersect { intersection_tool_union(&tools)? } else { tools };
apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
}
/// [`apply_op`] with tools whose lineage is known: the bodies it makes or changes keep theirs.
fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Operation, targets: &[String]) -> Result<()> {
let tools = if op == Operation::Intersect { intersection_tool_union(tools)? } else { tools };
// Participants: the feature's own targets, else its explicit participants, else the bodies
// of its component (and what it made itself): other components are left alone.
let targets: &[String] = if targets.is_empty() { &f.participants } else { targets };
@@ -942,16 +958,17 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
if op == Operation::Cut || op == Operation::Intersect {
return Err(DocError::Invalid("there is no body to cut or intersect".into()));
}
for (k, b) in tools.into_iter().enumerate() {
for (k, t) in tools.into_iter().enumerate() {
let name = new_name(state, f, k);
state.bodies.push(ModelBody::new(name, b, f.id));
state.bodies.push(ModelBody::traced_new(name, t, f.id));
}
return Ok(());
}
if matches!(op, Operation::Cut | Operation::Intersect) {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
for &i in &target_idx {
if let Some(mb) = state.bodies.get(i).filter(|b| state.sheets.iter().any(|s| s.body == b.name)) {
sheet_boolean_unchanged(&mb.body, &tools, op)?;
sheet_boolean_unchanged(&mb.body, &bodies, op)?;
}
}
}
@@ -961,7 +978,12 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
// name; a tool that meets none joins the first target as a separate lump. A sheet
// metal body keeps its own identity: only the first body met may be one.
let mut live: Vec<String> = target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
let union = |a: &Body, b: &Body| kernel::boolean(a, b, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()));
// Each union keeps the lineage of what it joins, so the joined body's faces keep
// their names (the tool's new faces are named by their roles).
let union = |a: &Traced, b: &Traced| -> Result<Traced> {
let u = kernel::boolean(&a.body, &b.body, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()))?;
Ok(Traced::step(u, &[a, b]))
};
for tool in &tools {
let mut met: Vec<String> = Vec::new();
for name in &live {
@@ -970,21 +992,21 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
if mb.body.is_mesh() || (!met.is_empty() && sheet) {
continue;
}
if mb.body.meets(tool)? {
if mb.body.meets(&tool.body)? {
met.push(name.clone());
}
}
let Some(keep) = met.first().or(live.first()).cloned() else { return Ok(()) };
let Some(base) = state.body(&keep).map(|b| b.body.clone()) else { continue };
let Some(base) = state.body(&keep).map(ModelBody::traced) else { continue };
let mut acc = union(&base, tool)?;
for name in met.iter().skip(1) {
if let Some(b) = state.body(name) {
acc = union(&acc, &b.body)?;
acc = union(&acc, &b.traced())?;
}
}
let absorbed: std::collections::BTreeSet<String> = met.into_iter().skip(1).collect();
if let Some(mb) = state.bodies.iter_mut().find(|b| b.name == keep) {
*mb = ModelBody::new(mb.name.clone(), acc, mb.feature);
*mb = ModelBody::traced_new(mb.name.clone(), acc, mb.feature);
}
state.bodies.retain(|b| !absorbed.contains(&b.name));
for t in state.threads.iter_mut().filter(|t| absorbed.contains(&t.body)) {
@@ -999,17 +1021,17 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
let mut remove = Vec::new();
for &ti in &target_idx {
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.body.clone())) else { continue };
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.traced())) else { continue };
for t in &tools {
acc = match acc {
Some(a) => kernel::boolean(&a, t, bop)?,
Some(a) => kernel::boolean(&a.body, &t.body, bop)?.map(|r| Traced::step(r, &[&a, t])),
None => None,
};
}
match acc {
Some(b) => {
if let Some(mb) = state.bodies.get_mut(ti) {
*mb = ModelBody::new(mb.name.clone(), b, mb.feature);
*mb = ModelBody::traced_new(mb.name.clone(), b, mb.feature);
}
}
None => remove.push(ti),
@@ -1035,9 +1057,12 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
split.push(mb);
continue;
}
for (k, p) in pieces.into_iter().enumerate() {
// Each piece's faces are named after the whole's, worked out once for the whole
// (so pieces of one face in different bodies are told apart).
let lineages = crate::lineage::Lineage::lumps(&mb.body, mb.lineage().as_deref(), &pieces);
for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() {
let name = if k == 0 { mb.name.clone() } else { format!("{} ({k})", mb.name) };
split.push(ModelBody::new(name, p, mb.feature));
split.push(ModelBody::new(name, p, mb.feature).with_lineage(lineage));
}
}
state.bodies = split;
@@ -1652,7 +1677,7 @@ struct ExtrudeConstructor {
}
/// Construct planar caps locally before placing the completed tool.
fn centered_extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketch: Option<&SolvedSketch>) -> Result<Vec<Traced>> {
if !plane.origin.is_finite()
|| !plane.x.is_finite()
|| !plane.y.is_finite()
@@ -1662,11 +1687,18 @@ fn centered_extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) ->
{
return Err(DocError::Invalid("extrude frame is not finite and orthonormal".into()));
}
// The faces' roles: the sketch curve under each side, the caps by where they lie.
let (lo, hi) = if start <= end { (start, end) } else { (end, start) };
let roles = crate::lineage::ProfileRoles {
sketch,
region: Some(region),
caps: [Some(crate::lineage::extrude_cap_role(lo)), Some(crate::lineage::extrude_cap_role(hi))],
};
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
// wire can tilt an otherwise exact planar cap through numeric cancellation.
let bodies = kernel::extrude(&Plane::XY, regions, start, end)?;
let bodies = kernel::extrude(&Plane::XY, std::slice::from_ref(region), start, end)?;
if *plane == Plane::XY {
return Ok(bodies);
return Ok(bodies.into_iter().map(|body| roles.tool(body, None)).collect());
}
let n = plane.normal();
let m = [
@@ -1675,12 +1707,18 @@ fn centered_extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) ->
[n.x, n.y, n.z, 0.0],
[plane.origin.x, plane.origin.y, plane.origin.z, 1.0],
];
bodies.into_iter().map(|body| kernel::transform_matrix(&body, m).map_err(DocError::from)).collect()
bodies
.into_iter()
.map(|body| {
let moved = kernel::transform_matrix(&body, m)?;
Ok(roles.tool(moved, Some(&body)))
})
.collect()
}
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
feature_tools_with_constructor(doc, vals, f, st, &mut None)
Ok(feature_tools_with_constructor(doc, vals, f, st, &mut None)?.into_iter().map(|t| t.body).collect())
}
fn feature_tools_with_constructor(
@@ -1689,7 +1727,8 @@ fn feature_tools_with_constructor(
f: &Feature,
st: &ModelState,
constructor: &mut Option<ExtrudeConstructor>,
) -> Result<Vec<Body>> {
) -> Result<Vec<Traced>> {
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
match &f.kind {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
@@ -1732,10 +1771,17 @@ fn feature_tools_with_constructor(
let sign = if extent.to.is_none() && extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) };
let start_plane = ss.plane.offset(from);
return Ok(regions
let caps = [
Some(crate::lineage::extrude_cap_role(from)),
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { from - len } else { from + len })),
];
return regions
.iter()
.map(|r| kernel::extrude_tapered(&start_plane, r, len, sign, taper))
.collect::<std::result::Result<Vec<_>, _>>()?);
.map(|r| {
let body = kernel::extrude_tapered(&start_plane, r, len, sign, taper)?;
Ok(crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }.tool(body, None))
})
.collect();
}
let off = from
+ match &extent.start_offset {
@@ -1771,12 +1817,12 @@ fn feature_tools_with_constructor(
let mut untrimmed = extent.to.is_none() && extent.from.is_none() && !extent.through_all;
for r in &regions {
if *operation == Operation::Join {
let exact = centered_extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?;
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
if exact.iter().all(|tool| {
st.bodies
.iter()
.filter(|b| targets.is_empty() || targets.contains(&b.name))
.any(|b| kernel::can_join_touching_faces(&b.body, tool))
.any(|b| kernel::can_join_touching_faces(&b.body, &tool.body))
}) {
// Preserve a whole touching face: extending into a chamfer replaces
// the sewable seam with a coincident curved intersection.
@@ -1789,22 +1835,23 @@ fn feature_tools_with_constructor(
let original = r;
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
untrimmed &= l2 == lo + off && h2 == hi + off && &r == original;
tools.extend(centered_extrude(&ss.plane, std::slice::from_ref(&r), l2, h2)?);
tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?);
}
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
let start = if hi > 0.0 { lo + off } else { hi + off };
tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
tools = trim_to_body(tools, tb, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
}
if untrimmed {
*constructor = Some(ExtrudeConstructor { plane: ss.plane, interval: (lo + off, hi + off) });
}
if *operation == Operation::NewBody && extent.taper.is_none() {
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000);
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &bodies, 10_000_000);
let source_curves = cap_bindings.as_ref().map(|_| crate::config::ExactSketchCurves::new(&ss));
tools = tools
.into_iter()
.enumerate()
.map(|(body_index, body)| {
.map(|(body_index, Traced { body, lineage })| {
let curves: Vec<_> = body
.face_sources()
.into_iter()
@@ -1818,9 +1865,9 @@ fn feature_tools_with_constructor(
.collect();
let caps =
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
body.with_extrude_certificates(f.id, *sketch, &curves)?
.with_extrude_cap_certificates(f.id, *sketch, &caps)
.map_err(DocError::from)
// Certificates set face sources only: the faces, and their lineage, stay.
let body = body.with_extrude_certificates(f.id, *sketch, &curves)?.with_extrude_cap_certificates(f.id, *sketch, &caps)?;
Ok(Traced { body, lineage })
})
.collect::<Result<Vec<_>>>()?;
}
@@ -1894,7 +1941,9 @@ fn feature_tools_with_constructor(
// (the piece that starts at the profile).
let curved = to.and_then(|p| curved_target_any(st, p));
let curved_body = curved.and_then(|bi| st.bodies.get(bi));
let build_curved = |regions: &[Region2], angle_axis: Vec3, tb: &ModelBody| -> Result<Vec<Body>> {
// Each region's body: its sides named by the sketch curves, its caps start and end.
let tool = |r: &Region2, body: Body, built: Option<&Body>| crate::lineage::ProfileRoles::swept(Some(&ss), Some(r)).tool(body, built);
let build_curved = |regions: &[Region2], angle_axis: Vec3, tb: &ModelBody| -> Result<Vec<Traced>> {
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64));
let flat = |q: Vec3| {
@@ -1910,9 +1959,10 @@ fn feature_tools_with_constructor(
let far = tb.mesh().positions.iter().map(|q| angle_of(*q)).fold(0.0, f64::max);
let sweep = (far + 0.05).min(std::f64::consts::TAU - 1e-3);
let bodies = kernel::revolve(&ss.plane, regions, o, d, sweep)?;
let tools = regions.iter().zip(bodies).map(|(r, body)| tool(r, body, None)).collect();
let n = ss.plane.normal();
let tol_side = |q: Vec3| if flat(q).dot(u0) > 0.0 { ((q - ss.plane.origin).dot(n)).abs() } else { f64::MAX };
trim_to_body(bodies, &tb.body, tol_side)
trim_to_body(tools, tb, tol_side)
};
if *convention == crate::RevolveConvention::LegacyAxisStart
&& let Some(tb) = curved_body
@@ -1930,18 +1980,22 @@ fn feature_tools_with_constructor(
let bodies = kernel::revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?;
let m = crate::rigid(Vec3::ZERO, origin_w, rotation_axis, region_extent.start);
for body in bodies {
out.push(if region_extent.start.abs() < 1e-12 { body } else { kernel::transform_matrix(&body, m)? });
out.push(if region_extent.start.abs() < 1e-12 {
tool(region, body, None)
} else {
tool(region, kernel::transform_matrix(&body, m)?, Some(&body))
});
}
}
return Ok(out);
}
let bodies = kernel::revolve(&ss.plane, &regions, o, d, ang)?;
if start.abs() < 1e-12 {
return Ok(bodies);
return Ok(regions.iter().zip(bodies).map(|(r, body)| tool(r, body, None)).collect());
}
// Turned back to start before the profile.
let m = crate::rigid(Vec3::ZERO, origin_w, axis_w, -start);
Ok(bodies.iter().map(|b| kernel::transform_matrix(b, m)).collect::<std::result::Result<Vec<_>, _>>()?)
regions.iter().zip(bodies).map(|(r, body)| Ok(tool(r, kernel::transform_matrix(&body, m)?, Some(&body)))).collect()
}
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.point.is_some()) => {
// A profile lofted to a sketch point (an apex).
@@ -1953,7 +2007,8 @@ fn feature_tools_with_constructor(
let ps = st.sketch(pt.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", pt.sketch)))?;
let id = pt.point.as_deref().unwrap_or_default();
let q = ps.sketch.points.iter().find(|p| p.id == id).ok_or_else(|| DocError::Unknown(format!("point `{id}` in {}", ps.name)))?;
Ok(vec![kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?])
let body = kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?;
Ok(vec![crate::lineage::ProfileRoles::swept(None, None).tool(body, None)])
}
FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => {
let base = &point(vals, base)?;
@@ -1992,7 +2047,7 @@ fn feature_tools_with_constructor(
let axis = if *clockwise { k * -1.0 } else { k };
// A left-handed coil turns about −axis and climbs along +axis: mirror the pitch.
let pitch = if *clockwise { -p } else { p };
Ok(vec![kernel::sweep_helix(&plane, &region, *base, axis, pitch, n)?])
Ok(unnamed(vec![kernel::sweep_helix(&plane, &region, *base, axis, pitch, n)?]))
}
FeatureKind::Loft { sections, .. } => {
let mut secs = Vec::new();
@@ -2004,7 +2059,10 @@ fn feature_tools_with_constructor(
let [r] = &regions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
secs.push((ss.plane, r.outer.clone()));
}
Ok(vec![kernel::loft(&secs)?])
// Its caps are the first and last sections; which section's curve a side follows is
// not recorded, so the sides are named from geometry.
let body = kernel::loft(&secs)?;
Ok(vec![crate::lineage::ProfileRoles::swept(None, None).tool(body, None)])
}
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
@@ -2021,12 +2079,13 @@ fn feature_tools_with_constructor(
let ps = mapped.as_ref();
let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default();
let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None);
// A path through 3D sketch curves follows them as points.
if let Some(pts) = path_points(ps, path, start)? {
return regions.iter().map(|r| kernel::sweep_path(&ss.plane, r, &pts).map_err(DocError::from)).collect();
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path(&ss.plane, r, &pts)?))).collect();
}
let segs = path_segments(ps, path, start)?;
regions.iter().map(|r| kernel::sweep(&ss.plane, r, &segs).map_err(DocError::from)).collect()
regions.iter().map(|r| Ok(tool(r, kernel::sweep(&ss.plane, r, &segs)?))).collect()
}
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
let ps =
@@ -2041,7 +2100,7 @@ fn feature_tools_with_constructor(
let (Some(a), Some(b)) = (pts.first().copied(), pts.get(1).copied()) else { return Err(DocError::Invalid("empty path".into())) };
let t = (b - a).normalized().ok_or_else(|| DocError::Invalid("path direction".into()))?;
let plane = Plane::from_normal(a, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?;
return Ok(vec![kernel::sweep_path(&plane, &pipe_region(vals, r, wall)?, &pts)?]);
return Ok(unnamed(vec![kernel::sweep_path(&plane, &pipe_region(vals, r, wall)?, &pts)?]));
}
let segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
let first = segs.first().ok_or_else(|| DocError::Invalid("empty path".into()))?;
@@ -2051,19 +2110,22 @@ fn feature_tools_with_constructor(
};
let t = tangent.normalized().ok_or_else(|| DocError::Invalid("path direction".into()))?;
let plane = Plane::from_normal(start, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?;
Ok(vec![kernel::sweep(&plane, &pipe_region(vals, r, wall)?, &segs)?])
Ok(unnamed(vec![kernel::sweep(&plane, &pipe_region(vals, r, wall)?, &segs)?]))
}
FeatureKind::Box { corner, length, width, height, .. } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let corner = point(vals, corner)?;
Ok(vec![kernel::box_solid(corner, corner + s)?])
Ok(vec![crate::lineage::box_tool(kernel::box_solid(corner, corner + s)?)])
}
FeatureKind::Cylinder { base, axis, radius, height, .. } => {
Ok(vec![kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?])
}
FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?]),
FeatureKind::Cylinder { base, axis, radius, height, .. } => Ok(vec![crate::lineage::cylinder_tool(kernel::cylinder(
point(vals, base)?,
*axis,
val(vals, radius, Kind::Length)?,
val(vals, height, Kind::Length)?,
)?)]),
FeatureKind::Sphere { center, radius, .. } => Ok(unnamed(vec![kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?])),
FeatureKind::Torus { center, major, minor, .. } => {
Ok(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?])
Ok(unnamed(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]))
}
FeatureKind::Hole { diameter, depth, hole, to, .. } => {
let (spots, dir) = hole_spots(st, f)?;
@@ -2075,7 +2137,7 @@ fn feature_tools_with_constructor(
let depth = hole_depth_to(st, &at, dir, to, depth)?;
tools.extend(hole_tools(vals, st, &at, dir, diameter, &depth, hole)?);
}
Ok(tools)
Ok(unnamed(tools))
}
_ => Err(DocError::Invalid(format!("{} cannot be patterned or mirrored yet", f.name))),
}
@@ -2505,8 +2567,9 @@ fn replay(
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
_ => kernel::fillet(&mb.body, &moved, r)?,
};
let nb = Traced::step(nb, &[&mb.traced()]);
if let Some(slot) = st.bodies.get_mut(ti) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
}
continue;
@@ -2524,33 +2587,36 @@ fn replay(
let at = hole_on_surface(st, crate::apply_point(m, *p), d);
let to = to.map(|t| crate::apply_point(m, t));
let depth = hole_depth_to(st, &at, d, &to, depth)?;
copies.extend(hole_tools(vals, st, &at, d, diameter, &depth, hole)?);
copies.extend(hole_tools(vals, st, &at, d, diameter, &depth, hole)?.into_iter().map(Traced::unnamed));
}
}
} else {
let tools = feature_tools(doc, vals, src, st)?;
for m in mats {
// Each copy's faces are named as the source feature's, with the instance.
let tools = feature_tools_with_constructor(doc, vals, src, st, &mut None)?;
for (k, m) in mats.iter().enumerate() {
for t in &tools {
copies.push(kernel::transform_matrix(t, *m)?);
copies.push(t.copied(kernel::transform_matrix(&t.body, *m)?, src.id, k + 1));
}
}
}
// Many cut copies clear of each other: one tool of many pieces, so the target is cut
// once instead of rebuilding an ever more complex body for every copy (#261).
if op == Operation::Cut
&& copies.len() >= 8
&& let Ok(Some(one)) = kernel::union_apart(&copies)
{
copies = vec![one];
if op == Operation::Cut && copies.len() >= 8 {
let bodies: Vec<Body> = copies.iter().map(|t| t.body.clone()).collect();
if let Ok(Some(one)) = kernel::union_apart(&bodies) {
// Each piece's faces keep the names of the copy it is.
let joined = Traced::step(one, &copies.iter().collect::<Vec<_>>());
copies = vec![joined];
}
}
// New bodies from a pattern join their source when they touch it (as one feature would).
if f.body_names.is_empty() {
apply_op(st, f, copies, op, &targets)?;
apply_traced(st, f, copies, op, &targets)?;
} else {
let start = st.bodies.iter().filter(|b| b.feature == f.id).count();
let mut named = f.clone();
named.body_names = f.body_names.iter().skip(start).cloned().collect();
apply_op(st, &named, copies, op, &targets)?;
apply_traced(st, &named, copies, op, &targets)?;
}
}
Ok(())
@@ -2617,16 +2683,20 @@ fn curved_target_any(st: &ModelState, p: Vec3) -> Option<usize> {
/// Tools run through `target`, cut back to its surface: each tool less the body, keeping the
/// piece(s) that touch the start (`dist_to_start` 0 there).
fn trim_to_body(tools: Vec<Body>, target: &Body, dist_to_start: impl Fn(Vec3) -> f64) -> Result<Vec<Body>> {
fn trim_to_body(tools: Vec<Traced>, target: &ModelBody, dist_to_start: impl Fn(Vec3) -> f64) -> Result<Vec<Traced>> {
let mut out = Vec::new();
let target_traced = target.traced();
for t in tools {
let Some(cut) = kernel::boolean(&t, target, BoolOp::Cut)? else { continue };
for lump in cut.lumps()? {
let Some(cut) = kernel::boolean(&t.body, &target.body, BoolOp::Cut)? else { continue };
let cut = Traced::step(cut, &[&t, &target_traced]);
let lumps = cut.body.lumps()?;
let lineages = crate::lineage::Lineage::lumps(&cut.body, cut.lineage.as_ref(), &lumps);
for (lump, lineage) in lumps.into_iter().zip(lineages) {
let tol = (lump.size() * 1e-4).max(1e-6);
let touches =
lump.tessellate((lump.size() * 1e-2).max(1e-2)).map(|m| m.positions.iter().any(|q| dist_to_start(*q) < tol)).unwrap_or(false);
if touches {
out.push(lump);
out.push(Traced { body: lump, lineage });
}
}
}
@@ -2709,26 +2779,40 @@ fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) ->
let n = picked.normal();
// By name first: the face (or a piece of it) with that name, facing the same way.
if let Some(name) = name {
let stem = crate::naming::strip_piece(name);
let mut found: Option<(f64, f64)> = None;
// (distance from `at`, plane offset along n, what to say about how it was found)
let mut found: Option<(f64, f64, Option<String>)> = None;
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
let names = crate::naming::face_names(b);
let named = crate::naming::faces_with_stem(b, name);
if named.is_empty() {
continue;
}
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(faces) = b.body.faces(tol) else { continue };
for f in faces {
let hit = names.get(f.index).is_some_and(|x| crate::naming::strip_piece(x) == stem);
let hit = named.contains(&f.index);
if let (true, Some(fnrm)) = (hit, f.plane_normal)
&& fnrm.dot(n) > 1.0 - 1e-6
{
let d = f.centroid.dist(at);
if found.is_none_or(|x| d < x.0) {
found = Some((d, f.centroid.dot(n)));
if found.as_ref().is_none_or(|x| d < x.0) {
// A name saved before format 14 is found as it was then, without a note.
let note = if crate::naming::legacy_name(name).is_some() {
None
} else if !crate::naming::face_has_name(b, f.index, name) {
Some(format!("face `{name}` was split; using the piece nearest the picked point"))
} else if !crate::naming::face_name_exact(b, f.index) && (f.centroid - at).dot(n).abs() > tol {
// Named from geometry and moved off the picked plane: geometry alone decided.
Some(format!("face `{name}` was found by geometry, not exact history; check it"))
} else {
None
};
found = Some((d, f.centroid.dot(n), note));
}
}
}
}
if let Some((_, off)) = found {
return (picked.offset(off - picked.origin.dot(n)), None);
if let Some((_, off, note)) = found {
return (picked.offset(off - picked.origin.dot(n)), note);
}
let (pl, w) = face_plane(st, picked, at, None);
let note = format!(
@@ -2862,7 +2946,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor)?;
// Cuts through sheet metal become cut-outs of its flat pattern.
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let handled = more::sheet_cut(f, st, &tools, targets, constructor.as_ref())?;
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
if !handled.is_empty() {
let effective = if targets.is_empty() { &f.participants } else { targets };
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
@@ -2883,10 +2968,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if rest.is_empty() {
return Ok(());
}
return apply_op(st, f, tools, *operation, &rest);
return apply_traced(st, f, tools, *operation, &rest);
}
}
apply_op(st, f, tools, *operation, targets)
apply_traced(st, f, tools, *operation, targets)
}
FeatureKind::Hole { thread, diameter, .. } => {
let (spots, dir) = hole_spots(st, f)?;
@@ -2922,8 +3007,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
// faceted (curved faces within a two-hundredth of the pitch).
let nb = kernel::faceted_boolean(&mb.body, &groove, BoolOp::Cut, t.pitch * 0.005)?
.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?;
// The groove's faces are its triangles, with no role to name them by.
let groove = crate::lineage::Lineage::unnamed_faces(groove.triangles.len());
let lineage = mb.traced().lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
let nb = Traced { body: nb, lineage };
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
Ok(())
}
@@ -2932,8 +3021,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let open = if *tangent_chain { smooth_chain(&mb, faces) } else { faces.clone() };
let nb = match direction.as_str() {
"" | "inside" => kernel::shell(&mb.body, &open, t)?,
let input = mb.traced();
// The shell, and the operands of the operation that made it.
let (nb, operands) = match direction.as_str() {
"" | "inside" => (kernel::shell(&mb.body, &open, t)?, vec![input]),
"outside" | "both" => {
// Walls grown out of the faces (or half out, half in): an offset body less a
// cavity, the open faces pushed out of the way.
@@ -2945,16 +3036,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let opened =
|p: &Vec3| open.iter().any(|q| crate::appearance::face_index_at(&mb, *q) == crate::appearance::face_index_at(&mb, *p));
let walls: Vec<Vec3> = samples.iter().filter(|p| !opened(p)).copied().collect();
let outer = kernel::offset_faces(&mb.body, &walls, out)?;
let cavity = if inn > 0.0 { kernel::offset_faces(&mb.body, &walls, -inn)? } else { mb.body.clone() };
let cavity = kernel::offset_faces(&cavity, &open, 2.0 * t + 1.0)?;
kernel::boolean(&outer, &cavity, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the shell removed everything".into()))?
let outer = Traced::step(kernel::offset_faces(&mb.body, &walls, out)?, &[&input]);
// The cavity's faces are the shell's inner walls, named after the faces they offset.
let inner = mb.before_as(crate::lineage::Operand::Inner(mb.name.clone()));
let cavity = if inn > 0.0 { Traced::step(kernel::offset_faces(&mb.body, &walls, -inn)?, &[&inner]) } else { inner };
let cavity = Traced::step(kernel::offset_faces(&cavity.body, &open, 2.0 * t + 1.0)?, &[&cavity]);
let cut = kernel::boolean(&outer.body, &cavity.body, BoolOp::Cut)?
.ok_or_else(|| DocError::Invalid("the shell removed everything".into()))?;
(cut, vec![outer, cavity])
}
o => return Err(DocError::Invalid(format!("unknown shell direction `{o}` (inside, outside or both)"))),
};
let nb = nb.with_shell_certificate(f.id)?;
// Certifying may join faces; their history stays over the same operands.
let nb = Traced::step(nb.with_shell_certificate(f.id)?, &operands.iter().collect::<Vec<_>>());
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
Ok(())
}
@@ -2963,9 +3059,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let pl = doc.resolve_plane_in(vals, neutral, 0, Some(st))?;
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let nb = kernel::draft(&mb.body, faces, &pl, *pull, a)?;
let nb = Traced::step(kernel::draft(&mb.body, faces, &pl, *pull, a)?, &[&mb.traced()]);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
Ok(())
}
@@ -3005,25 +3101,26 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
for p in cells {
// The cell: inside the tools that hold the point, outside the others.
let inside: Vec<bool> = tb.iter().map(|b| b.mesh().contains(*p)).collect();
let mut acc: Option<Body> = None;
let mut acc: Option<Traced> = None;
for (b, _) in tb.iter().zip(&inside).filter(|(_, i)| **i) {
acc = match acc {
None => Some(b.body.clone()),
Some(a) => kernel::boolean(&a, &b.body, BoolOp::Intersect)?,
None => Some(b.traced()),
Some(a) => kernel::boolean(&a.body, &b.body, BoolOp::Intersect)?.map(|r| Traced::step(r, &[&a, &b.traced()])),
};
}
let Some(mut cell) = acc else {
return Err(DocError::Invalid(format!("the point {p:?} is outside every tool: no enclosed cell there")));
};
for (b, _) in tb.iter().zip(&inside).filter(|(_, i)| !**i) {
cell = kernel::boolean(&cell, &b.body, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("a cell came out empty".into()))?;
let cut = kernel::boolean(&cell.body, &b.body, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("a cell came out empty".into()))?;
cell = Traced::step(cut, &[&cell, &b.traced()]);
}
made.push(cell);
}
if *remove_tools {
st.bodies.retain(|b| !tools.contains(&b.name));
}
apply_op(st, f, made, *operation, &[])
apply_traced(st, f, made, *operation, &[])
}
FeatureKind::SplitFace { faces, body, plane, tool, sketch } => {
let i = body_at(st, body, faces)?;
@@ -3061,8 +3158,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Plane(pl))?
}
};
let nb = Traced::step(nb, &[&mb.traced()]);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
Ok(())
}
@@ -3085,16 +3183,17 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if parts.len() < 2 {
return Err(DocError::Invalid("the tool does not split the body".into()));
}
let mut it = parts.into_iter();
let input = mb.traced();
let mut it = parts.into_iter().map(|part| Traced::step(part, &[&input]));
if let (Some(first), Some(slot)) = (it.next(), st.bodies.get_mut(i)) {
*slot = ModelBody::new(mb.name.clone(), first, mb.feature);
*slot = ModelBody::traced_new(mb.name.clone(), first, mb.feature);
}
for (k, b) in it.enumerate() {
let mut name = format!("{} ({})", mb.name, k + 1);
while st.body(&name).is_some() {
name.push('\'');
}
st.bodies.push(ModelBody::new(name, b, f.id));
st.bodies.push(ModelBody::traced_new(name, b, f.id));
}
Ok(())
}
@@ -3107,10 +3206,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let mut output = 0;
for n in bodies {
let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
for m in &mats {
let copy = kernel::transform_matrix(&mb.body, *m)?;
for (k, m) in mats.iter().enumerate() {
let copy = moved_copy(&mb, kernel::transform_matrix(&mb.body, *m)?, k + 1);
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::new(name, copy, f.id));
st.bodies.push(ModelBody::traced_new(name, copy, f.id));
output += 1;
}
}
@@ -3126,24 +3225,27 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
for n in bodies {
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { continue };
let mirrored = kernel::transform_matrix(&mb.body, m)?;
let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, m)?, 1);
if *combine {
let joined = kernel::boolean(&mb.body, &mirrored, BoolOp::Union)?
let joined = kernel::boolean(&mb.body, &mirrored.body, BoolOp::Union)?
.ok_or_else(|| DocError::Invalid("the mirror join produced nothing".into()))?;
for (k, p) in joined.lumps()?.into_iter().enumerate() {
let joined = Traced::step(joined, &[&mb.traced(), &mirrored]);
let pieces = joined.body.lumps()?;
let lineages = crate::lineage::Lineage::lumps(&joined.body, joined.lineage.as_ref(), &pieces);
for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() {
if k == 0 {
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name.clone(), p, mb.feature);
*slot = ModelBody::new(mb.name.clone(), p, mb.feature).with_lineage(lineage);
}
} else {
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::new(name, p, f.id));
st.bodies.push(ModelBody::new(name, p, f.id).with_lineage(lineage));
output += 1;
}
}
} else {
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::new(name, mirrored, f.id));
st.bodies.push(ModelBody::traced_new(name, mirrored, f.id));
output += 1;
}
}
@@ -3157,11 +3259,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
// 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);
match f.edge_names.get(i).filter(|n| !n.is_empty()).map(|n| (n, crate::naming::locate_edge(mb, n, *p))) {
Some((n, Ok(hit))) => {
if let Some(note) = hit.note(n) {
notes.push(note);
}
out.push(hit.point);
}
Some((_, Err(_))) | None => {
let mut w = None;
@@ -3191,28 +3294,38 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
} else {
resolve_edges(&mb.body, edges, &f.edge_refs, warning)?
};
let nb = match &f.kind {
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => kernel::fillet_chord(&mb.body, &edges, r)?,
let input = mb.traced();
// The blended body, and the lineage of what the last blend started from.
let (nb, from) = match &f.kind {
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => (kernel::fillet_chord(&mb.body, &edges, r)?, input),
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => {
let r2 = val(vals, radius2, Kind::Length)?;
let mut pts = vec![(0.0, r), (1.0, r2)];
for (t, e) in mid {
pts.push((*t, val(vals, e, Kind::Length)?));
}
let mut b = mb.body.clone();
// One edge at a time: each blend's lineage over the one before.
let mut b = input;
let mut from = None;
for p in &edges {
b = kernel::fillet_variable_points(&b, *p, *start, &pts, *smooth)?;
let next = Traced::step(kernel::fillet_variable_points(&b.body, *p, *start, &pts, *smooth)?, &[&b]);
from = Some(std::mem::replace(&mut b, next));
}
match from {
Some(prev) => (b.body, prev),
None => return Err(DocError::Invalid("no edges to fillet".into())),
}
b
}
FeatureKind::Fillet { .. } => kernel::fillet(&mb.body, &edges, r)?,
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?,
FeatureKind::Fillet { .. } => (kernel::fillet(&mb.body, &edges, r)?, input),
FeatureKind::Chamfer { distance2, angle, flip, .. } => (chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?, input),
_ => return Err(DocError::Invalid("blend".into())),
};
let nb = nb.with_preserved_planar_sources(&mb.body)?;
// Keeping the input's planar certificates may join faces; their history stays over
// the same operand.
let nb = Traced::step(nb.with_preserved_planar_sources(&mb.body)?, &[&from]);
let (name, feat) = (mb.name.clone(), mb.feature);
if let Some(slot) = st.bodies.get_mut(ti) {
*slot = ModelBody::new(name, nb, feat);
*slot = ModelBody::traced_new(name, nb, feat);
}
Ok(())
}
@@ -3221,11 +3334,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b };
apply_op(st, f, vec![b], *operation, &[])
apply_traced(st, f, vec![crate::lineage::box_tool(b)], *operation, &[])
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
apply_op(st, f, vec![b], *operation, &[])
apply_traced(st, f, vec![crate::lineage::cylinder_tool(b)], *operation, &[])
}
FeatureKind::Sphere { center, radius, operation } => {
let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?;
@@ -3241,13 +3354,13 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
let mut tb = Vec::new();
for t in tools {
tb.push(st.body(t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?.body.clone());
tb.push(st.body(t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?.traced());
}
if st.body(target).is_none() {
return Err(DocError::Unknown(format!("body `{target}`")));
}
let op = if *operation == Operation::NewBody { Operation::Join } else { *operation };
apply_op(st, f, tb, op, std::slice::from_ref(target))?;
apply_traced(st, f, tb, op, std::slice::from_ref(target))?;
if !keep_tools {
st.bodies.retain(|b| !tools.contains(&b.name));
}
@@ -3275,8 +3388,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
for n in bodies {
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
if let Some(mb) = st.bodies.get_mut(i) {
let nb = kernel::transform_matrix(&mb.body, m)?;
*mb = ModelBody::new(mb.name.clone(), nb, mb.feature);
let nb = Traced::step(kernel::transform_matrix(&mb.body, m)?, &[&mb.traced()]);
*mb = ModelBody::traced_new(mb.name.clone(), nb, mb.feature);
}
}
Ok(())
@@ -3285,9 +3398,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let d = val(vals, distance, Kind::Length)?;
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let nb = kernel::offset_faces(&mb.body, faces, d)?;
let nb = Traced::step(kernel::offset_faces(&mb.body, faces, d)?, &[&mb.traced()]);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
Ok(())
}
@@ -3303,9 +3416,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
}
let all = kernel::with_same_surface(&mb.body, &picked);
let nb = kernel::delete_faces(&mb.body, &all)?;
let nb = Traced::step(kernel::delete_faces(&mb.body, &all)?, &[&mb.traced()]);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
Ok(())
}
@@ -3319,9 +3432,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
return Err(DocError::Invalid("no bodies to bound".into()));
}
let pad = Vec3::new(m, m, m);
let b = kernel::box_solid(bb.min - pad, bb.max + pad)?;
let b = crate::lineage::numbered_tool(kernel::box_solid(bb.min - pad, bb.max + pad)?);
let name = unique_body_name(st, f.body_names.first().map(String::as_str).unwrap_or("Bounding"));
st.bodies.push(ModelBody::new(name, b, f.id));
st.bodies.push(ModelBody::traced_new(name, b, f.id));
Ok(())
}
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
@@ -3335,8 +3448,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
for n in bodies {
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
if let Some(mb) = st.bodies.get_mut(i) {
let nb = kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?;
*mb = ModelBody::new(mb.name.clone(), nb, mb.feature);
let nb = Traced::step(kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?, &[&mb.traced()]);
*mb = ModelBody::traced_new(mb.name.clone(), nb, mb.feature);
}
}
Ok(())
@@ -3368,7 +3481,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
for (k, b) in imp.bodies.iter().enumerate() {
let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
let name = unique_body_name(st, base);
st.bodies.push(ModelBody::new(name, b.body.clone().with_import_certificate(&digest, f.id)?, f.id));
let b = crate::lineage::numbered_tool(b.body.clone().with_import_certificate(&digest, f.id)?);
st.bodies.push(ModelBody::traced_new(name, b, f.id));
}
if !imp.warnings.is_empty() {
let n = imp.warnings.len();
@@ -3381,6 +3495,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
}
/// `copy`, a moved copy of `source` (instance `instance` of a pattern or mirror): its faces are
/// named after the source's.
fn moved_copy(source: &ModelBody, copy: Body, instance: usize) -> Traced {
Traced::step(copy, &[&source.before_as(crate::lineage::Operand::Copy { body: source.name.clone(), instance })])
}
/// Mesh bodies of a MeshImport feature.
fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
let mut out = Vec::with_capacity(meshes.len());
@@ -3445,14 +3565,14 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
&& let Ok(t) = kernel::transform_matrix(&b.body, *m)
{
// Moving a body keeps its faces (and their order): it keeps their names.
let names = crate::naming::face_names(b);
let names = crate::naming::NameCell::same(b);
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
nb.component = b.component;
nb.transferred = b.transferred;
nb.source_name = b.source_name.clone();
nb.source_transform = crate::mat_mul(m, &b.source_transform);
nb.imported = b.imported.clone();
nb.names = crate::naming::NameCell::known(names);
nb.names = names;
*b = nb;
}
}
@@ -3518,7 +3638,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
placed.imported = b.imported.clone();
// Placement preserves topology: face ownership remains that of the
// source model, including modifiers on component instances.
placed.names = crate::naming::NameCell::known(crate::naming::face_names(b));
placed.names = crate::naming::NameCell::same(b);
out.bodies.push(placed);
}
if primary {
+1 -1
View File
@@ -51,7 +51,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
let (index, _, _) = crate::project::nearest_face(&mb.mesh(), picked)
.ok_or_else(|| DocError::Invalid("selected cylinder face cannot be resolved".into()))?;
if crate::naming::face_names(&mb).get(index as usize) != Some(name) {
if !crate::naming::face_has_name(&mb, index as usize, name) {
return Err(DocError::Invalid("selected cylinder face is ambiguous".into()));
}
at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?;
+106 -3
View File
@@ -39,13 +39,18 @@
//! - 13: exact planar shell face anchors retain the shell producer, original certified
//! source and distinct inner/outer role. Version 12 anchors retain their identities;
//! migration never guesses attachments for older designs.
//! - 14: persistent names follow the kernel's exact face history. Pieces of a split face are
//! named by the faces around them (`F2:top#[F2:side:l4,F4:side:l4]`) instead of numbered by
//! where their centres lay (`F2:top#2`). Names stored by older formats were worked out from
//! geometry, and a few of them name a face that history names differently; they get the
//! legacy mark (`?F2:top#2`) and are found again as the build that saved them found them.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 13;
pub const FORMAT: u32 = 14;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -58,8 +63,70 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 12] =
[|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
const JSON_STEPS: [JsonStep; 13] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
v4_to_v5,
v5_to_v6,
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
v13_to_v14,
];
/// 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. Every place a design stores such a name
/// is marked: fillets' and chamfers' `edge_names`, features' `face_names`, face and edge
/// references (`"type": "face"|"edge"` with a `name`), sketch links' `face_name`, joint snaps'
/// `face`, and face appearances (but not strict ones, whose names are certified lineage).
fn v13_to_v14(v: &mut Value) -> Result<()> {
fn walk(v: &mut Value) {
match v {
Value::Object(o) => {
let kind = o.get("type").and_then(Value::as_str).map(str::to_string);
let blend = matches!(kind.as_deref(), Some("fillet" | "chamfer"));
for (key, x) in o.iter_mut() {
match (key.as_str(), x) {
// Other features' edge names are sheet metal's own, not these.
("edge_names", Value::Array(names)) if blend => names.iter_mut().for_each(mark),
("face_names", Value::Array(names)) => names.iter_mut().for_each(mark),
("face_name" | "face", n @ Value::String(_)) => mark(n),
("name", n @ Value::String(_)) if matches!(kind.as_deref(), Some("face" | "edge")) => mark(n),
(_, x) => walk(x),
}
}
}
Value::Array(a) => a.iter_mut().for_each(walk),
_ => {}
}
}
walk(v);
// Face appearances carry the face's name without a type tag.
if let Some(Value::Array(faces)) = v.get_mut("appearances").and_then(|a| a.get_mut("faces")) {
for face in faces.iter_mut().filter(|f| f.get("strict_name").and_then(Value::as_bool) != Some(true)) {
if let Some(n) = face.get_mut("name") {
mark(n);
}
}
}
Ok(())
}
/// A stored name with the legacy mark (an empty name stays empty: no reference).
fn mark(n: &mut Value) {
if let Value::String(s) = n
&& !s.is_empty()
&& crate::naming::legacy_name(s).is_none()
{
s.insert(0, crate::naming::LEGACY_MARK);
}
}
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
@@ -596,6 +663,42 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests {
use super::*;
#[test]
fn version_thirteen_names_keep_their_meaning_under_the_legacy_mark() {
let file = serde_json::json!({
"format": "solvecraft/13",
"features": [
{"type": "fillet", "edge_names": ["F2:side:l2|F2:top#2~1", ""], "face_names": ["F2:top#1", ""]},
{"type": "sheet_flange", "edge_names": ["base:l1:top"]},
{"type": "sketch", "plane": {"type": "face", "name": "F2:top#1", "at": [0, 0, 0]},
"sketch": {"links": [{"face_name": "F2:top#2"}]}},
{"type": "construction_axis", "def": {"a": {"type": "edge", "name": "F2:start|F2:side:l1"}, "b": {"type": "face", "name": "F2:start"}}},
{"name": "Hole #2", "type": "extrude"},
{"type": "thread", "face": [1, 2, 3]}
],
"assembly": {"joints": [{"snap": {"type": "face_center", "face": "F2:top#2"}}]},
"appearances": {"faces": [{"body": "Body1", "name": "F2:top#1"}, {"body": "Body1", "name": "F2:+x", "strict_name": true}]}
});
let mut v = file.clone();
v13_to_v14(&mut v).unwrap();
assert_eq!(v["features"][0]["edge_names"], serde_json::json!(["?F2:side:l2|F2:top#2~1", ""]));
assert_eq!(v["features"][0]["face_names"], serde_json::json!(["?F2:top#1", ""]));
assert_eq!(v["features"][1]["edge_names"][0], "base:l1:top", "sheet metal's edge names are its own");
assert_eq!(v["features"][2]["plane"]["name"], "?F2:top#1");
assert_eq!(v["features"][2]["sketch"]["links"][0]["face_name"], "?F2:top#2");
assert_eq!(v["features"][3]["def"]["a"]["name"], "?F2:start|F2:side:l1");
assert_eq!(v["features"][3]["def"]["b"]["name"], "?F2:start");
assert_eq!(v["features"][4]["name"], "Hole #2", "a feature's own name is not a face name");
assert_eq!(v["features"][5]["face"], serde_json::json!([1, 2, 3]));
assert_eq!(v["assembly"]["joints"][0]["snap"]["face"], "?F2:top#2");
assert_eq!(v["appearances"]["faces"][0]["name"], "?F2:top#1");
assert_eq!(v["appearances"]["faces"][1]["name"], "F2:+x", "strict appearance names are certified lineage");
// The step never marks twice.
let once = v.clone();
v13_to_v14(&mut v).unwrap();
assert_eq!(v, once);
}
#[test]
fn version_nine_wire_spaces_preserve_saved_points_and_explicit_markers() {
use solvecraft_geom::Vec3;
+396
View File
@@ -0,0 +1,396 @@
//! 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;
+68
View File
@@ -0,0 +1,68 @@
use super::*;
#[test]
fn piece_names_parse_into_stem_and_bounding_faces() {
use crate::naming::{parent_piece, strip_edge_piece, strip_piece};
use crate::piece_names::piece_parts;
assert_eq!(strip_piece("F2:top#[F2:side:l4,F4:side:l4]"), "F2:top");
assert_eq!(strip_piece("F2:top#[a]#[b]"), "F2:top");
assert_eq!(parent_piece("F2:top#[a]#[b]"), Some("F2:top#[a]"));
assert_eq!(strip_piece("F2:top#[a]#2"), "F2:top");
assert_eq!(strip_piece("F2:top#2"), "F2:top", "an older design's numbered piece");
assert_eq!(strip_piece("F2:top"), "F2:top");
assert_eq!(parent_piece("F2:top"), None);
assert_eq!(piece_parts("F2:top#[F2:side:l4,F5:blend:A#[x,y]|B]"), Some(("F2:top", vec!["F2:side:l4", "F5:blend:A#[x,y]|B"])));
assert_eq!(piece_parts("F2:top#[]"), Some(("F2:top", vec![])));
assert_eq!(strip_edge_piece("F2:side:l2|F2:top#[F4:side:l2]~2"), "F2:side:l2|F2:top");
assert_eq!(strip_edge_piece("F2:side:l2#1|F2:top#2"), "F2:side:l2|F2:top");
// Hostile names never panic and are their own stems.
for n in ["", "#", "#[", "]", "a#[", "a]", "#[]", "a#[b", "x#[[]]]", "é#[ü]", "#1", "a#[b]#", "~", "a~"] {
let _ = (strip_piece(n), parent_piece(n), piece_parts(n), strip_edge_piece(n));
}
assert_eq!(strip_piece("#[]"), "#[]");
}
fn named(names: &[&str]) -> Vec<Named> {
names.iter().map(|n| Named { name: n.to_string(), aliases: Vec::new(), verified: true, origins: None }).collect()
}
#[test]
fn pieces_are_told_apart_by_the_faces_only_they_touch() {
// A top face cut in two by a slot: each piece touches the front and back walls (common),
// its own end wall and its own slot wall.
let faces = named(&["top", "top", "front", "back", "left", "right", "slot:a", "slot:b"]);
let adjacent = [(0, 2), (0, 3), (0, 4), (0, 6), (1, 2), (1, 3), (1, 5), (1, 7)];
let names = qualify(faces, &adjacent);
assert_eq!(names.faces[0], "top#[left,slot:a]");
assert_eq!(names.faces[1], "top#[right,slot:b]");
assert!(names.verified(0) && names.verified(1));
// A stored name finds its piece, though the faces around it have changed since.
assert_eq!(matches(&names, 0, "top#[left]"), Some(Match::Piece));
assert_eq!(matches(&names, 1, "top#[left]"), Some(Match::Stem));
assert_eq!(matches(&names, 0, "top"), Some(Match::Piece), "a piece of the named face");
assert_eq!(matches(&names, 0, "top#[left,slot:a]"), Some(Match::Exact));
assert_eq!(matches(&names, 2, "top"), None);
}
#[test]
fn pieces_nothing_tells_apart_are_numbered_and_unverified() {
let faces = named(&["top", "top", "ring"]);
let names = qualify(faces, &[(0, 2), (1, 2)]);
assert_eq!(names.faces[0], "top#1");
assert_eq!(names.faces[1], "top#2");
assert!(!names.verified(0) && !names.verified(1));
}
#[test]
fn a_merged_face_takes_the_shared_stem_or_the_first_name_and_answers_to_the_others() {
let m = merged(named(&["top#[a]", "top#[b]"])).unwrap();
assert_eq!(m.name, "top");
assert_eq!(m.aliases, vec!["top#[a]".to_string(), "top#[b]".to_string()]);
let m = merged(named(&["top#1", "top#2"])).unwrap();
assert_eq!(m.name, "top", "numbered pieces joined again");
let m = merged(named(&["F2:top", "F9:top"])).unwrap();
assert_eq!(m.name, "F2:top");
assert_eq!(m.aliases, vec!["F9:top".to_string()]);
let names = qualify(vec![m], &[]);
assert_eq!(matches(&names, 0, "F9:top"), Some(Match::Alias));
}
+4
View File
@@ -22,11 +22,15 @@ pub mod face_anchor;
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
pub mod format;
mod format_offsets;
mod history_names;
pub use format_offsets::whole_profile_selection_from_value;
pub mod joints;
mod lineage;
pub mod motion;
mod name_lookup;
pub mod naming;
mod params;
mod piece_names;
mod profile_refs;
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
pub mod plastic;
+388
View File
@@ -0,0 +1,388 @@
//! What each face of a body a feature made came from, in the model's terms.
//!
//! The kernel reports, for every operation, which faces and edges of its operands each face and
//! edge of its result came from ([`History`]). A feature may run several operations (a tool
//! built from a sketch, then cut from each target, then split into lumps) whose operands are
//! the feature's tools, intermediate bodies or bodies of the model before it. A [`Lineage`]
//! composes those histories into one over the operands that names can be read from: bodies of
//! the model before the feature (whose faces already have names), moved copies of them, and
//! bodies the feature built itself (whose faces are named by their role: the sketch curve they
//! sweep, the cap they are). Naming (`history_names`) reads it; nothing here samples geometry.
use std::sync::{Arc, OnceLock};
use solvecraft_geom::Region2;
use solvecraft_kernel::Body;
use solvecraft_kernel::history::{Change, FaceOrigin, FaceProv, History};
use crate::SolvedSketch;
/// Something a body's faces came from, and how its faces are named.
#[derive(Debug)]
pub(crate) enum Operand {
/// A body of the model before the feature (by name): its faces keep their names.
Body(String),
/// A moved copy of a body of the model before the feature, instance `instance` of a pattern
/// or mirror: its faces are named after their sources (`<name>@F<feature>.<instance>`).
Copy { body: String, instance: usize },
/// The cavity side of a shell grown from a body's faces (by name): each face is the inner
/// wall of the face it offsets (`F<feature>:inner:<name>`).
Inner(String),
/// A body the feature built: each face's role (`side:<curve>`, `top`, `+x`, …; the first is
/// its name, the others aliases), empty where the role is not known exactly.
Made(Vec<Vec<String>>),
/// A moved copy of a body another feature (`feature`) builds, instance `instance` of a
/// pattern or mirror of that feature: its faces named by their roles in that feature
/// (`F<feature>:<role>@F<pattern>.<instance>`).
CopiedTool { roles: Vec<Vec<String>>, feature: u64, instance: usize },
/// A body the feature made on the way to its result (a cut body before it falls into lumps),
/// named as a whole first so pieces of one face in different results are told apart.
Stage(Box<Stage>),
}
/// An intermediate body of a feature, with its lineage and, once worked out, its names.
#[derive(Debug)]
pub(crate) struct Stage {
/// The whole body (a stand-in model body: its mesh is worked out once for every lump).
pub body: crate::ModelBody,
pub lineage: Lineage,
pub names: OnceLock<Arc<crate::history_names::Names>>,
}
/// The history of a body over operands that can be named.
#[derive(Clone, Debug)]
pub(crate) struct Lineage {
pub(crate) history: History,
pub(crate) operands: Vec<Arc<Operand>>,
}
impl Lineage {
/// `body` as it is: every face and edge its own, named as `operand` names them.
pub(crate) fn of(body: &Body, operand: Operand) -> Lineage {
Lineage { history: History::identity(body), operands: vec![Arc::new(operand)] }
}
/// The lineage of `result`, made by one kernel operation from operands whose lineages are
/// `inputs` (in the operation's operand order). `None` where the operation reports no
/// history or its history cannot be composed with theirs: the result is then named from
/// geometry, flagged as unverified.
pub(crate) fn step(result: &Body, inputs: &[&Lineage]) -> Option<Lineage> {
let mut history = result.history()?.clone();
if history.operands != inputs.len() {
return None;
}
// From the last operand back: each one's operands come in at its place.
for (k, input) in inputs.iter().enumerate().rev() {
if let Err(e) = history.substitute(k, &input.history) {
log::debug!("no lineage: {e}");
return None;
}
}
let operands: Vec<Arc<Operand>> = inputs.iter().flat_map(|l| l.operands.iter().cloned()).collect();
(history.operands == operands.len()).then_some(Lineage { history, operands })
}
/// This lineage for a moved copy of what feature `feature` built (instance `instance` of a
/// pattern or mirror of it): the faces it built are named as that feature's, copied.
pub(crate) fn copied(&self, feature: u64, instance: usize) -> Lineage {
let operands = self
.operands
.iter()
.map(|op| match op.as_ref() {
Operand::Made(roles) => Arc::new(Operand::CopiedTool { roles: roles.clone(), feature, instance }),
_ => op.clone(),
})
.collect();
Lineage { history: self.history.clone(), operands }
}
/// A tool of `faces` faces and no edges (a mesh's triangles) whose faces have no role.
pub(crate) fn unnamed_faces(faces: usize) -> Lineage {
let faces = (0..faces).map(|face| FaceProv { origins: vec![FaceOrigin::Face { operand: 0, face }], change: Change::Kept }).collect();
Lineage { history: History { operands: 1, faces, edges: Vec::new() }, operands: vec![Arc::new(Operand::Made(Vec::new()))] }
}
/// The lineages of `pieces`, the lumps of `whole` (whose lineage is `lineage`): their faces
/// named after the whole's, worked out once for the whole, so pieces of one face that end up
/// in different bodies are told apart. `None` for a piece whose history is not known.
/// A body in one piece is its own lump: its lineage as it is.
pub(crate) fn lumps(whole: &Body, lineage: Option<&Lineage>, pieces: &[Body]) -> Vec<Option<Lineage>> {
let Some(lineage) = lineage else { return vec![None; pieces.len()] };
if pieces.len() < 2 {
return vec![Some(lineage.clone()); pieces.len()];
}
let body = crate::ModelBody::new(String::new(), whole.clone(), 0);
let stage = Arc::new(Operand::Stage(Box::new(Stage { body, lineage: lineage.clone(), names: OnceLock::new() })));
pieces
.iter()
.map(|piece| {
let history = piece.history()?.clone();
(history.operands == 1).then(|| Lineage { history, operands: vec![stage.clone()] })
})
.collect()
}
}
/// A body made during a feature, with its lineage (`None`: not known; it is then named from
/// geometry).
#[derive(Clone, Debug)]
pub(crate) struct Traced {
pub body: Body,
pub lineage: Option<Lineage>,
}
impl Traced {
/// A body the feature built whose faces' roles are not known: its faces are named from
/// geometry, those of the bodies it is combined with by their history.
pub fn unnamed(body: Body) -> Traced {
let roles = vec![Vec::new(); body.face_count()];
Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body }
}
/// A body the feature built, its faces' roles given (one per face), else not known.
pub fn made(body: Body, roles: Option<Vec<Vec<String>>>) -> Traced {
match roles.filter(|r| r.len() == body.face_count()) {
Some(roles) => Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body },
None => Traced::unnamed(body),
}
}
/// `copy`, this tool moved: instance `instance` of a pattern or mirror of feature `feature`,
/// which built it.
pub fn copied(&self, copy: Body, feature: u64, instance: usize) -> Traced {
let lineage = self.lineage.as_ref().and_then(|l| Lineage::step(&copy, &[&l.copied(feature, instance)]));
Traced { body: copy, lineage }
}
/// `result`, made by one kernel operation from `inputs` (in its operand order).
pub fn step(result: Body, inputs: &[&Traced]) -> Traced {
let lineages: Option<Vec<&Lineage>> = inputs.iter().map(|t| t.lineage.as_ref()).collect();
let lineage = lineages.and_then(|l| Lineage::step(&result, &l));
Traced { body: result, lineage }
}
}
/// How the bodies a feature builds from a profile name their faces: what each profile segment's
/// face is called, and each cap.
pub(crate) struct ProfileRoles<'a> {
/// The sketch the profile is in (its curves name the side faces), if any.
pub sketch: Option<&'a SolvedSketch>,
/// The region the body was built from, in the sketch's coordinates; its segments (outer loop,
/// then holes) number [`FaceOrigin::Profile`].
pub region: Option<&'a Region2>,
/// The start and end caps' roles (`None`: not known).
pub caps: [Option<String>; 2],
}
impl<'a> ProfileRoles<'a> {
/// The roles of the faces of a body built from the profile, whose history (over no
/// operands: it was built, not changed) is `history`. A face swept from profile segments is
/// `side:<curve>` of the sketch curve each lies on exactly; a cap is its cap's role. Unknown
/// (empty) where a segment lies on no single sketch curve, or the history is not a builder's.
pub fn of(&self, history: &History) -> Option<Vec<Vec<String>>> {
if history.operands != 0 {
return None;
}
let segments: Vec<&solvecraft_geom::Seg2> =
self.region.map(|r| std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).collect()).unwrap_or_default();
let curves = self.sketch.map(crate::config::ExactSketchCurves::new);
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find(segments.get(segment)?).map(|id| format!("side:{id}")) };
let roles = history
.faces
.iter()
.map(|face| {
let mut roles: Vec<String> = Vec::new();
for origin in &face.origins {
let role = match *origin {
FaceOrigin::Profile { segment } => curve(segment),
FaceOrigin::Cap { end } => self.caps.get(usize::from(end)).cloned().flatten(),
_ => None,
};
match role {
Some(role) if !roles.contains(&role) => roles.push(role),
Some(_) => {}
// One unknown origin leaves the face unknown: never a partial guess.
None => return Vec::new(),
}
}
roles
})
.collect();
Some(roles)
}
/// A body swept from a profile (revolve, sweep, loft): its sides named by the sketch curves,
/// its caps `start` and `end`.
pub fn swept(sketch: Option<&'a SolvedSketch>, region: Option<&'a Region2>) -> ProfileRoles<'a> {
ProfileRoles { sketch, region, caps: [Some("start".into()), Some("end".into())] }
}
/// A tool built from the profile (and then moved, from `built`, when given), its faces named
/// by their roles where its history gives them.
pub fn tool(&self, body: Body, built: Option<&Body>) -> Traced {
let history = match built {
Some(built) => built_history(&body, built),
None => body.history().cloned(),
};
Traced::made(body, history.and_then(|h| self.of(&h)))
}
}
/// A box primitive, its faces named by the side they face (`+x`, `-z`, …), as its construction
/// records them.
pub(crate) fn box_tool(body: Body) -> Traced {
let roles: Option<Vec<Vec<String>>> = body.face_sources().map(|sources| {
sources
.iter()
.map(|source| match source {
Some(solvecraft_kernel::FaceSource::Box { axis, high, .. }) => {
["x", "y", "z"].get(usize::from(*axis)).map(|a| vec![format!("{}{a}", if *high { "+" } else { "-" })]).unwrap_or_default()
}
_ => Vec::new(),
})
.collect()
});
Traced::made(body, roles)
}
/// A cylinder primitive: its wall `side`, its caps `bottom` (at the base) and `top`.
pub(crate) fn cylinder_tool(body: Body) -> Traced {
let roles = body.history().filter(|h| h.operands == 0).map(|h| {
h.faces
.iter()
.map(|f| match f.origins.as_slice() {
[FaceOrigin::Cap { end }] => vec![if *end { "top" } else { "bottom" }.to_string()],
origins if !origins.is_empty() && origins.iter().all(|o| matches!(o, FaceOrigin::Profile { .. })) => vec!["side".to_string()],
_ => Vec::new(),
})
.collect()
});
Traced::made(body, roles)
}
/// Faces named by their place in a body built as a whole (an imported part's faces in the
/// file's order): `face:<k>`.
pub(crate) fn numbered_tool(body: Body) -> Traced {
let roles = (0..body.face_count()).map(|k| vec![format!("face:{k}")]).collect();
Traced::made(body, Some(roles))
}
/// The role of an extrude's cap at `height` along the sketch normal: on the sketch plane, above
/// it or below it.
pub(crate) fn extrude_cap_role(height: f64) -> String {
let tol = 1e-6 * (1.0 + height.abs());
if height.abs() <= tol {
"start".into()
} else if height > 0.0 {
"top".into()
} else {
"bottom".into()
}
}
/// The history over no operands of a body built and then moved (or otherwise rebuilt in place)
/// by `moved`'s operation from `built`.
pub(crate) fn built_history(moved: &Body, built: &Body) -> Option<History> {
let mut history = moved.history()?.clone();
if history.operands == 1 {
history.substitute(0, built.history()?).ok()?;
Some(history)
} else {
(history.operands == 0).then_some(history)
}
}
/// How a body of the model being evaluated came to be during the feature being evaluated.
#[derive(Clone, Debug, Default)]
pub(crate) enum Made {
/// Made or changed by the feature, how not known: named from geometry.
#[default]
Unknown,
/// As it was before the feature.
Before,
/// Made or changed by the feature, with this lineage.
Traced(Arc<Lineage>),
}
impl crate::ModelBody {
/// A body made or changed by the feature, with what it came from.
pub(crate) fn traced_new(name: String, made: Traced, feature: u64) -> Self {
crate::ModelBody::new(name, made.body, feature).with_lineage(made.lineage)
}
/// This body with what it came from during the feature (`None`: not known).
pub(crate) fn with_lineage(mut self, lineage: Option<Lineage>) -> Self {
self.made = match lineage {
Some(l) => Made::Traced(Arc::new(l)),
None => Made::Unknown,
};
self
}
/// This body as an operand of the feature being evaluated: a body of the model before it, or
/// what the feature made of one so far.
pub(crate) fn traced(&self) -> Traced {
let lineage = match &self.made {
Made::Before => Some(Lineage::of(&self.body, Operand::Body(self.name.clone()))),
Made::Traced(l) => Some(l.as_ref().clone()),
Made::Unknown => None,
};
Traced { body: self.body.clone(), lineage }
}
/// This body, as it was before the feature, as an operand named by `operand` (a moved copy of
/// it, the cavity side of a shell grown from it); `None` once the feature has changed it.
pub(crate) fn before_as(&self, operand: Operand) -> Traced {
let lineage = matches!(self.made, Made::Before).then(|| Lineage::of(&self.body, operand));
Traced { body: self.body.clone(), lineage }
}
/// The lineage a feature left this body with, when it made or changed it with one.
pub(crate) fn lineage(&self) -> Option<Arc<Lineage>> {
match &self.made {
Made::Traced(l) => Some(l.clone()),
Made::Before | Made::Unknown => None,
}
}
}
/// Name the pieces a body fell into after their faces: of the bodies in `bodies` that are lumps
/// of one body (made in the same step, from one stage), the one whose least face name sorts first
/// takes the first of their body names, and so on. The kernel's shell order, which named them
/// before, can swap when an upstream edit moves the cut; their faces' names follow history.
/// `origin` is the feature that made them, for naming their faces.
pub(crate) fn order_lumps(bodies: &mut [crate::ModelBody], origin: &Arc<crate::naming::NamingOrigin>) {
let mut families: Vec<(*const Operand, Vec<usize>)> = Vec::new();
for (i, b) in bodies.iter().enumerate() {
let Made::Traced(l) = &b.made else { continue };
let [op] = l.operands.as_slice() else { continue };
if !matches!(op.as_ref(), Operand::Stage(_)) {
continue;
}
let key = Arc::as_ptr(op);
match families.iter_mut().find(|(k, _)| *k == key) {
Some((_, members)) => members.push(i),
None => families.push((key, vec![i])),
}
}
for (_, members) in families.into_iter().filter(|(_, m)| m.len() > 1) {
let feature = members.first().and_then(|i| bodies.get(*i)).map(|b| b.feature);
if members.iter().any(|i| bodies.get(*i).map(|b| b.feature) != feature) {
continue;
}
let least = |b: &crate::ModelBody| {
let mut named = b.clone();
named.names = crate::naming::NameCell::traced(Some(origin.clone()), b.lineage());
crate::naming::face_names(&named).iter().min().cloned()
};
let keys: Option<Vec<(String, usize)>> = members.iter().map(|i| bodies.get(*i).and_then(least).map(|k| (k, *i))).collect();
let Some(mut keys) = keys else { continue };
keys.sort();
let names: Vec<String> = members.iter().filter_map(|i| bodies.get(*i).map(|b| b.name.clone())).collect();
for (name, (_, i)) in names.into_iter().zip(keys) {
if let Some(b) = bodies.get_mut(i) {
b.name = name;
}
}
}
}
+333
View File
@@ -0,0 +1,333 @@
//! Finding a face or edge again by the name a feature stored for it.
//!
//! The face with that name is the face; else the one face answering to it (a face merged into
//! it, or the named piece told apart by the faces around it, which still bound it); else, when
//! several faces are pieces of the named one, the piece nearest the reference's point, and the
//! feature says the face was split. Names saved before format 14 ([`LEGACY_MARK`]) are found
//! among the names geometry gives, as the builds that saved them found them. A face whose name
//! was worked out from geometry, found where the reference's point no longer lies, is reported:
//! only geometry decided.
use solvecraft_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::{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.
pub const LEGACY_MARK: char = '?';
/// A name saved before format 14, without its mark (`None`: an exact name).
pub fn legacy_name(name: &str) -> Option<&str> {
name.strip_prefix(LEGACY_MARK)
}
/// Where a stored edge name was found.
#[derive(Clone, Copy, Debug)]
pub struct EdgeHit {
/// A point on the edge: the reference's own point when it lies on it, else the edge's middle.
pub point: Vec3,
/// The named edge is not one edge any more: the piece nearest the point was taken.
pub split: bool,
/// Whether the names of the faces it separates follow exact history (and the edge is not
/// one of several between them, numbered by where they lie).
pub verified: bool,
/// 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 (followed through splits and merges of those faces); else the
/// piece of it nearest `near` (`split`). `Err` explains when there is none.
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"));
}
let m = b.mesh();
let names = edge_names(b);
let faces = naming::names(b);
let mid = |i: usize| edge_mid(&m, i);
// Several edges between the same two faces are numbered by where they lie: not exact.
let verified = |i: usize| {
m.edge_faces.get(i).is_some_and(|fs| fs.iter().all(|f| faces.verified(*f as usize)))
&& names.get(i).is_some_and(|n| strip_edge_number(n).len() == n.len())
};
let exact = |i: usize| -> Result<EdgeHit, String> {
let on = edge_near(b, &m, i, near);
mid(i)
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
.ok_or_else(|| "edge".into())
};
if let Some(i) = names.iter().position(|n| n == name) {
return exact(i);
}
// By the faces it separates: the best way any edge matches.
let wanted = strip_edge_number(name);
let mut best: Option<(Match, Vec<usize>)> = None;
for (i, fs) in m.edge_faces.iter().enumerate() {
let [fa, fb] = fs.as_slice() else { continue };
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted) else { continue };
keep_best(&mut best, level, i);
}
let (level, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
if let ([i], true) = (cands.as_slice(), level >= Match::Piece) {
return exact(*i);
}
cands
.iter()
.filter_map(|i| mid(*i).map(|p| (p, *i)))
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
.map(|(p, i)| EdgeHit { point: p, split: true, verified: verified(i), on_point: false, legacy: false })
.ok_or_else(|| format!("edge `{name}` no longer exists"))
}
/// Whether the picked point `near` lies on edge `i` of body `b` (whose mesh is `m`): then a
/// reference keeps it.
fn edge_near(b: &ModelBody, m: &Mesh, i: usize, near: Vec3) -> bool {
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)))
}
/// The point of face `face` of a mesh nearest `p`, and how far it is.
fn nearest_on_face(m: &Mesh, face: usize, p: Vec3) -> Option<(f64, Vec3)> {
let mut best: Option<(f64, Vec3)> = None;
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if *f as usize != face {
continue;
}
let Some([a, b, c]) = m.tri(t) else { continue };
let q = closest(p, a, b, c);
let d = q.dist(p);
if best.is_none_or(|x| d < x.0) {
best = Some((d, q));
}
}
best
}
/// The middle of edge `i` of a mesh (along its polyline).
fn edge_mid(m: &Mesh, 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()
}
/// [`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 { point: if on { near } else { p }, split: false, 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))
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
.map(|p| EdgeHit { point: p, split: true, 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).
fn edge_match(faces: &Names, a: usize, b: usize, wanted: &str) -> Option<Match> {
let mut best = None;
for (i, _) in wanted.match_indices('|') {
let (Some(x), Some(y)) = (wanted.get(..i), wanted.get(i + 1..)) else { continue };
for (p, q) in [(a, b), (b, a)] {
if let (Some(m1), Some(m2)) = (crate::history_names::matches(faces, p, x), crate::history_names::matches(faces, q, y)) {
best = best.max(Some(m1.min(m2)));
}
}
}
best
}
/// Keep `item` among the best matches so far.
fn keep_best<T>(best: &mut Option<(Match, Vec<T>)>, level: Match, item: T) {
match best {
Some((b, items)) if *b == level => items.push(item),
Some((b, _)) if *b > level => {}
_ => *best = Some((level, vec![item])),
}
}
/// Where a stored face name was found.
#[derive(Clone, Copy, Debug)]
pub struct FaceHit {
/// The body (its index in the model's bodies).
pub body: usize,
/// The face (its index in the body).
pub face: usize,
/// The point on the face nearest the reference's point.
pub point: Vec3,
/// The named face is not one face any more (split, or found on several bodies): the piece
/// nearest the point was taken.
pub split: bool,
/// The named face was merged into this one.
pub merged: bool,
/// Whether the face's name follows exact history.
pub verified: bool,
/// Whether the reference's point still lies on the face (so a name worked out from geometry
/// 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,
}
impl FaceHit {
/// What a feature says about how it found a face by its stored name `name` (`None`: exactly).
pub fn note(&self, name: &str) -> Option<String> {
if self.split {
Some(format!("face `{name}` was split; using the piece nearest the picked point"))
} else if self.merged {
Some(format!("face `{name}` was merged into another face; using that face"))
} else if !self.verified && !self.on_point && !self.legacy {
Some(format!("face `{name}` was found by geometry, not exact history; check it"))
} else {
None
}
}
}
impl EdgeHit {
/// What a feature says about how it found an edge by its stored name `name` (`None`: exactly).
pub fn note(&self, name: &str) -> Option<String> {
if self.split {
Some(format!("edge `{name}` was split; using the piece nearest the picked point"))
} else if !self.verified && !self.on_point && !self.legacy {
Some(format!("edge `{name}` was found by geometry, not exact history; check it"))
} else {
None
}
}
}
/// The face a stored face name finds, on any body: the face with that name, or the one face
/// answering to it (merged into it, or the named piece told apart by the faces around it); else
/// the piece of it nearest `p` (`split`).
pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p)
}
/// [`locate_face`] among `bodies` (with their indices).
fn locate_in<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &str, p: Vec3) -> Option<FaceHit> {
if let Some(old) = legacy_name(name) {
return legacy_locate(bodies, old, p);
}
let mut best: Option<(Match, Vec<(usize, &ModelBody, usize)>)> = None;
for (bi, b) in bodies {
let names = naming::names(b);
for face in 0..names.faces.len() {
if let Some(level) = crate::history_names::matches(&names, face, name) {
keep_best(&mut best, level, (bi, b, face));
}
}
}
let (level, faces) = best?;
let unique = faces.len() == 1 && level >= Match::Piece;
let mut nearest: Option<(f64, usize, &ModelBody, usize, Vec3)> = None;
for (bi, b, face) in faces {
if let Some((d, q)) = nearest_on_face(&b.mesh(), face, p)
&& nearest.as_ref().is_none_or(|n| d < n.0)
{
nearest = Some((d, bi, b, face, q));
}
}
let (distance, body, b, face, point) = nearest?;
let on_point = distance <= (b.body.size() * 1e-3).max(1e-3);
Some(FaceHit {
body,
face,
point,
split: !unique,
merged: level == Match::Alias,
verified: naming::names(b).verified(face),
on_point,
legacy: false,
})
}
/// [`locate_face`] for a name saved before format 14 (without its mark), as those builds found
/// 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, bool, 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 best.is_none_or(|x| d < x.0) {
best = Some((d, bi, *f as usize, q, !exact, tol));
}
}
}
best.map(|(d, body, face, point, split, tol)| FaceHit {
body,
face,
point,
split,
merged: false,
verified: false,
on_point: d <= tol,
legacy: true,
})
}
/// Whether face `index` of a body is the face a stored name names: that name (or one merged into
/// it); for a name saved before format 14, that name among the names those builds gave.
pub fn face_has_name(b: &ModelBody, index: usize, name: &str) -> bool {
match legacy_name(name) {
Some(old) => legacy_face_names(b).get(index).is_some_and(|n| n == old),
None => crate::history_names::matches(&naming::names(b), index, name).is_some_and(|m| m >= Match::Alias),
}
}
/// 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.
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.aliases.iter().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()
}
/// [`locate_face`] on one body.
pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit> {
locate_in(std::iter::once((0, body)), name, p)
}
+131 -127
View File
@@ -1,16 +1,26 @@
//! Persistent names of faces and edges (plan/adr/0002-persistent-naming.md).
//! Persistent names of faces and edges.
//!
//! 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.
//! A fillet stores the edges it rounds, a sketch the face it sits on: by name, so that an
//! upstream edit that adds, removes, splits or reorders faces does not move them to another
//! face. Every face of an evaluated body is named after how it was made: a face that came from 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`/`top`/`bottom`/`end`,
//! `F<feature>:blend:<edge>`, `F<feature>:inner:<face>`, …). Pieces of a split face are told
//! apart by the faces around them (`F2:top#[F2:side:l4,F4:side:l4]`). Edges are named by the
//! faces they separate (`<face A>|<face B>`, `~k` for several edges between the same two).
//!
//! Where the feature that made a body reports exact history (`crate::lineage`), the names follow
//! it (`crate::history_names`). Bodies without history (made by operations that do not report
//! it yet, imported meshes, legacy chains) are named from geometry: sample points of each face
//! are looked up on the input's faces. Those names are marked unverified, and references that
//! resolve through them say so. Names are worked out lazily, on first request.
use std::sync::{Arc, OnceLock};
use solvecraft_geom::{Mesh, Seg2, Vec2, Vec3};
use crate::history_names::Names;
use crate::lineage::Lineage;
use crate::{Feature, FeatureKind, ModelBody, ModelState};
/// How a body came to be: the feature and the model just before it.
@@ -27,21 +37,43 @@ pub struct NamingOrigin {
#[derive(Clone, Debug, Default)]
pub struct NameCell {
pub origin: Option<Arc<NamingOrigin>>,
names: Arc<OnceLock<Arc<Vec<String>>>>,
/// 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).
copy_of: Option<Arc<ModelBody>>,
}
impl NameCell {
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
NameCell { origin, names: Arc::new(OnceLock::new()) }
Self::traced(origin, None)
}
/// Names already known (a moved copy of a body keeps its faces' names).
/// 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, ..NameCell::default() }
}
/// Names already known, taken as they are.
pub fn known(names: Arc<Vec<String>>) -> Self {
let legacy = OnceLock::new();
let _ = legacy.set(names.clone());
NameCell { legacy: Arc::new(legacy), ..Self::shared(Arc::new(Names::given(names)), None) }
}
/// The names of `body` (a moved copy of a body keeps its faces' names, and how sure they are).
pub fn same(body: &ModelBody) -> Self {
Self::shared(names(body), Some(Arc::new(body.clone())))
}
fn shared(names: Arc<Names>, copy_of: Option<Arc<ModelBody>>) -> Self {
let cell = OnceLock::new();
let _ = cell.set(names);
NameCell { origin: None, names: Arc::new(cell) }
NameCell { names: Arc::new(cell), copy_of, ..NameCell::default() }
}
}
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face};
pub use crate::piece_names::parent_piece;
/// Deeper chains than this are named by position only (bounded work and stack).
const MAX_DEPTH: usize = 400;
@@ -80,7 +112,7 @@ fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
.collect()
}
fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
pub(crate) 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 {
@@ -158,12 +190,53 @@ fn face_count(b: &ModelBody) -> usize {
/// Names of a body's faces (by B-rep face index).
pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
names(b).faces.clone()
}
/// Whether face `index`'s name follows exact history (`false`: worked out from geometry, which an
/// upstream edit can fool).
pub fn face_name_exact(b: &ModelBody, index: usize) -> bool {
names(b).verified(index)
}
/// A body's names, with what finding them again needs: from the history of the feature that
/// made it where it reports one, else from geometry (unverified).
pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
b.names
.names
.get_or_init(|| {
// Exact history needs no mesh; geometry does (its face count is the mesh's).
let names = match b.names.origin.as_ref() {
Some(o) if o.depth < MAX_DEPTH => b
.names
.lineage
.as_ref()
.and_then(|l| crate::history_names::derive(b, o, l))
.unwrap_or_else(|| Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b))),
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[]),
};
Arc::new(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 => derive(b, o, nf),
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)
@@ -171,14 +244,31 @@ pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
.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> {
/// 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)))
}
/// Work out a body's face names from the feature that made it and the model before it, by
/// 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 face_names(input).as_ref().clone();
return names_of(input).as_ref().clone();
}
let m = b.mesh();
let size = b.body.size().max(1e-6);
@@ -214,7 +304,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
.take(64)
.map(|x| {
let n = face_count(x);
(x, FaceIndex::new(&x.mesh(), n), face_names(x))
(x, FaceIndex::new(&x.mesh(), n), names_of(x))
})
.collect();
let fid = o.feature.id;
@@ -229,7 +319,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
.iter()
.filter(|x| !x.body.is_mesh())
.take(64)
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), face_names(x)))
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), names_of(x)))
.collect();
let mut best: Option<(usize, usize, Vec<Option<String>>)> = None;
for (k, mat) in o.mats.iter().enumerate() {
@@ -245,7 +335,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
}
}
let empty = Vec::new();
let mut names: Vec<String> = names
let names: Vec<String> = names
.into_iter()
.enumerate()
.map(|(fi, n)| {
@@ -261,7 +351,6 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
})
})
.collect();
number_pieces(&mut names, &pts, '#');
names
}
@@ -347,37 +436,23 @@ fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol:
votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1) && votes.get(1).is_none_or(|(_, other)| n > other)).map(|(n, _)| n.clone())
}
/// A face name without its piece number (`#k`).
/// A face name without its piece qualifiers: the face it is a piece of (`F2:top` for
/// `F2:top#[F4:side:l1]`, for pieces of pieces, and for the numbered pieces of older designs).
pub fn strip_piece(n: &str) -> &str {
strip_mark(n, '#')
let mut stem = n;
while let Some(parent) = parent_piece(stem) {
stem = parent;
}
stem
}
/// 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.
/// An edge name without piece numbers: its own (`~k`) and its faces' (`#[…]`, and the numbered
/// pieces of older designs), 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
crate::piece_names::strip_edge_piece(n)
}
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.
/// Edges sharing a name: 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() {
@@ -429,18 +504,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
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"
}
);
return format!("F{fid}:{}", crate::lineage::extrude_cap_role((c - ss.plane.origin).dot(n)));
}
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}"))
@@ -576,8 +640,12 @@ fn seg_dist(s: &Seg2, q: Vec2) -> f64 {
/// 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> {
edge_names_from(b, &face_names(b))
}
/// Names of a body's edges from its faces' names `faces`.
fn edge_names_from(b: &ModelBody, faces: &[String]) -> Vec<String> {
let m = b.mesh();
let faces = face_names(b);
let mut names: Vec<String> = m
.edge_faces
.iter()
@@ -595,35 +663,7 @@ pub fn edge_names(b: &ModelBody) -> Vec<String> {
/// 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"))
locate_edge(b, name, near).map(|hit| (hit.point, hit.split))
}
/// Names of the edges nearest each point on a body (for storing references).
@@ -675,28 +715,10 @@ pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
}
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
/// `true` for "it was split"); `None` when no face has the name.
/// `true` for "not exactly that face": split, or merged into another); `None` when no face has
/// the name.
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
let stem = strip_piece(name);
let mut best: Option<(f64, Vec3, bool)> = None;
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
let names = face_names(b);
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 best.is_none_or(|(bd, _, _)| d < bd) {
best = Some((d, q, !exact));
}
}
}
best.map(|(_, q, split)| (q, split))
locate_face(st, name, p).map(|hit| (hit.point, hit.split || hit.merged))
}
/// Resolve a legacy named face only on its recorded source body. Return the
@@ -706,25 +728,7 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
if body.body.is_mesh() {
return None;
}
let names = face_names(body);
let mesh = body.mesh();
let stem = strip_piece(name);
let mut best: Option<(f64, usize, Vec3, bool)> = None;
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
let index = usize::try_from(*face).ok()?;
let Some(candidate) = names.get(index) else { continue };
let exact = candidate == name;
if !exact && strip_piece(candidate) != stem {
continue;
}
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
let q = closest(p, a, b, c);
let distance = q.dist(p) - if exact { 1e-9 } else { 0. };
if best.is_none_or(|(old, _, _, _)| distance < old) {
best = Some((distance, index, q, !exact));
}
}
best.map(|(_, index, q, split)| (index, q, split))
crate::name_lookup::locate_on(body, name, p).map(|hit| (hit.face, hit.point, hit.split || hit.merged))
}
#[cfg(test)]
+139
View File
@@ -0,0 +1,139 @@
//! The grammar of piece names: a face split into pieces is named `<face>#[<faces around it>]`
//! (the faces only that piece touches), `#k` after that when nothing tells pieces apart, and
//! designs saved before format 14 numbered pieces `<face>#k`. Parsing never panics on hostile
//! names: an unbalanced or empty qualifier is simply not one.
/// The face a piece name is a piece of (one qualifier off), `None` when it is not a piece.
pub fn parent_piece(n: &str) -> Option<&str> {
piece_split(n).map(|(stem, _)| stem)
}
/// A piece name's stem and the faces its qualifier lists (`F2:top`, [`F2:side:l4`, `F4:side:l4`]
/// for `F2:top#[F2:side:l4,F4:side:l4]`; none for an older design's numbered piece).
pub(crate) fn piece_parts(n: &str) -> Option<(&str, Vec<&str>)> {
let (stem, around) = piece_split(n)?;
Some((stem, around.map(split_top_level).unwrap_or_default()))
}
/// `n` as stem and qualifier contents: `stem#[…]`, `stem#[…]#k` (a numbered tie) or `stem#k` (an
/// older design's numbered piece, no contents).
fn piece_split(n: &str) -> Option<(&str, Option<&str>)> {
let mut rest = n;
let mut numbered = false;
if let Some(i) = rest.rfind('#')
&& let Some(digits) = rest.get(i + 1..)
&& !digits.is_empty()
&& digits.bytes().all(|b| b.is_ascii_digit())
{
rest = rest.get(..i)?;
numbered = true;
}
if let Some(inner_end) = rest.len().checked_sub(1).filter(|_| rest.ends_with(']')) {
let open = matching_open(rest, inner_end)?;
let stem = rest.get(..open.checked_sub(1)?)?;
if rest.get(open - 1..open) == Some("#") && !stem.is_empty() {
return Some((stem, rest.get(open + 1..inner_end)));
}
}
(numbered && !rest.is_empty()).then_some((rest, None))
}
/// The index of the `[` matching the `]` at byte `close`.
fn matching_open(s: &str, close: usize) -> Option<usize> {
let bytes = s.as_bytes();
let mut depth = 0usize;
for i in (0..=close).rev() {
match bytes.get(i)? {
b']' => depth += 1,
b'[' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
/// `s` split at the commas outside brackets (a qualifier's list of faces).
fn split_top_level(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let (mut depth, mut from) = (0usize, 0usize);
for (i, b) in s.bytes().enumerate() {
match b {
b'[' => depth += 1,
b']' => depth = depth.saturating_sub(1),
b',' if depth == 0 => {
out.extend(s.get(from..i).filter(|x| !x.is_empty()));
from = i + 1;
}
_ => {}
}
}
out.extend(s.get(from..).filter(|x| !x.is_empty()));
out
}
/// An edge name without piece qualifiers: its own number (`~k`) and its faces' (`#[…]`, and the
/// numbered pieces of older designs), so pieces of a split edge, or of an edge between split
/// faces, match.
pub(crate) fn strip_edge_piece(n: &str) -> String {
let n = strip_edge_number(n);
let mut out = String::with_capacity(n.len());
let bytes = n.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes.get(i) == Some(&b'#') {
match bytes.get(i + 1) {
Some(b'[') => {
// Skip the bracketed qualifier (and a tie number after it).
let mut depth = 0usize;
let mut j = i + 1;
while j < bytes.len() {
match bytes.get(j) {
Some(b'[') => depth += 1,
Some(b']') => {
depth = depth.saturating_sub(1);
if depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
i = j + 1;
continue;
}
Some(d) if d.is_ascii_digit() => {
i += 1;
while bytes.get(i).is_some_and(u8::is_ascii_digit) {
i += 1;
}
continue;
}
_ => {}
}
}
// Copy one whole character.
let ch = n.get(i..).and_then(|rest| rest.chars().next());
match ch {
Some(c) => {
out.push(c);
i += c.len_utf8();
}
None => break,
}
}
out
}
/// An edge name without its own number (`~k`).
pub(crate) fn strip_edge_number(n: &str) -> &str {
match n.rfind('~') {
Some(i) if n.get(i + 1..).is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())) => n.get(..i).unwrap_or(n),
_ => n,
}
}
+1 -1
View File
@@ -497,7 +497,7 @@ fn refresh_links_in(
&& name != stem
&& st.body(body).is_some_and(|target| {
(certified_planar_construction(target, index) || target.body.face_was_coalesced(index))
&& crate::naming::face_names(target).get(index).is_some_and(|current| current == stem)
&& crate::naming::face_has_name(target, index, stem)
});
if removed_piece {
sk.set_link_lost(&l.id, true);
@@ -109,6 +109,22 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam
legacy["format"] = json!("solvecraft/9");
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
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.
let mark = |name: &Option<String>| name.as_ref().map(|n| format!("{}{n}", solvecraft_doc::naming::LEGACY_MARK));
let mut saved_plane = saved_plane;
if let solvecraft_doc::PlaneRef::Face { name, .. } = &mut saved_plane {
*name = mark(name);
}
let paint: Vec<_> = paint
.into_iter()
.map(|mut face| {
if !face.strict_name {
face.name = mark(&face.name);
}
face
})
.collect();
let FeatureKind::Sketch { plane, .. } = &reopened.doc.feature(badge).unwrap().kind else { panic!() };
assert_eq!(plane, &saved_plane);
assert_eq!(reopened.doc.appearances.faces, paint);
@@ -156,10 +172,10 @@ fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":20,"profiles":[{"point":[9.5,0]}],"body_name":"Sleeve"}));
let names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
// Preserve the legacy centroid-family name even though strict paint uses
// the separately certified outer source curve.
// The outer wall is named after its own sketch curve: its history says so. (Builds before
// format 14 named it after the curve nearest its centre, the inner circle; see below.)
let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0);
assert!(name.contains("side:inner"));
assert!(name.contains("side:outer"));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
@@ -172,6 +188,13 @@ fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
assert!((volume - std::f64::consts::PI * 380. - 21.).abs() < 0.2, "volume {volume}");
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
legacy["format"] = json!("solvecraft/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.
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(solvecraft_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.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
+4 -1
View File
@@ -262,7 +262,10 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
.body
.faces(tol)?
.iter()
.map(|f| json!({"index": f.index, "name": names.get(f.index), "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal}))
.map(|f| {
json!({"index": f.index, "name": names.get(f.index), "exact": solvecraft_doc::naming::face_name_exact(b, f.index),
"area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal})
})
.collect();
Ok(json!({"body": b.name, "faces": faces}))
}
+2
View File
@@ -671,6 +671,8 @@ mod delete_face_tests;
#[cfg(test)]
mod extent_tests;
#[cfg(test)]
mod naming_history_tests;
#[cfg(test)]
mod pattern_suppress_tests;
#[cfg(test)]
mod profile_curves_tests;
+273
View File
@@ -0,0 +1,273 @@
//! Persistent names follow the kernel's exact face history: a reference to a face or edge finds
//! the face it was made on after upstream edits that split, merge, mirror, shrink or move what it
//! names, or says it could not. It never quietly attaches to another face, which is what finding
//! names again by sampling geometry could do.
use serde_json::{Value, json};
use solvecraft_geom::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn id(s: &Session, name: &str) -> u64 {
s.doc.features.iter().rev().find(|f| f.name == name).map(|f| f.id).unwrap_or_else(|| panic!("feature {name}"))
}
fn warning(s: &Session, name: &str) -> Option<String> {
s.model.result(id(s, name)).and_then(|r| r.warning.clone())
}
fn error(s: &Session, name: &str) -> Option<String> {
s.model.result(id(s, name)).and_then(|r| r.error.clone())
}
/// Whether any body holds `p`.
fn solid_at(s: &Session, p: [f64; 3]) -> bool {
s.model.state().bodies.iter().any(|b| b.body.contains(Vec3::new(p[0], p[1], p[2])))
}
/// Which body holds `p`.
fn body_at(s: &Session, p: [f64; 3]) -> Option<String> {
s.model.state().bodies.iter().find(|b| b.body.contains(Vec3::new(p[0], p[1], p[2]))).map(|b| b.name.clone())
}
fn names(s: &Session, body: &str) -> Vec<(String, bool)> {
let state = s.model.state();
let b = state.body(body).unwrap_or_else(|| panic!("body {body}"));
let names = solvecraft_doc::naming::face_names(b);
names.iter().enumerate().map(|(i, n)| (n.clone(), solvecraft_doc::naming::face_name_exact(b, i))).collect()
}
/// A 40 x 30 x 10 block ("Block": sketch lines l1 bottom, l2 right, l3 top, l4 left).
fn block(s: &mut Session) {
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
}
/// A slot across the block from x = `slot_x` to `slot_x + slot_w`, from z = `floor` up through
/// the top: it splits the top (and, cut to the floor, the block) in two.
fn slot(s: &mut Session, floor: f64) {
run(s, "parameters.add", json!({"name": "slot_x", "expression": "15 mm"}));
run(s, "parameters.add", json!({"name": "slot_w", "expression": "10 mm"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [15, -5], "p1": [25, 35]}));
run(s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "slot_x"}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "slot_w"}));
run(s, "sketch.finish", json!({}));
let mut p = json!({"sketch": "SlotSketch", "distance": 12, "operation": "cut", "name": "Slot"});
if floor > 0.0 {
p["start_offset"] = json!(floor);
}
run(s, "solid.extrude", p);
}
fn change(s: &mut Session, name: &str, expression: &str) {
run(s, "parameters.change", json!({"name": name, "expression": expression}));
}
#[test]
fn faces_built_from_a_profile_are_named_exactly() {
let mut s = Session::default();
block(&mut s);
slot(&mut s, 5.0);
let block = id(&s, "Block");
let cut = id(&s, "Slot");
let n = names(&s, "Body1");
assert!(n.iter().all(|(_, exact)| *exact), "{n:?}");
let has = |want: &str| n.iter().any(|(x, _)| x == want);
// The top is in two pieces, each told apart by the walls only it touches.
assert!(has(&format!("F{block}:top#[F{block}:side:l4,F{cut}:side:l4]")), "{n:?}");
assert!(has(&format!("F{block}:top#[F{block}:side:l2,F{cut}:side:l2]")), "{n:?}");
// The slot's floor is its start cap (above the sketch plane), its walls its sketch curves.
for want in [format!("F{cut}:top"), format!("F{cut}:side:l2"), format!("F{cut}:side:l4"), format!("F{block}:start")] {
assert!(has(&want), "{want} in {n:?}");
}
}
#[test]
fn an_upstream_cut_that_splits_a_face_keeps_the_edge_that_bounded_it() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 2, "name": "Round"}));
let block = id(&s, "Block");
let round = s.doc.find_feature("Round").cloned().unwrap();
assert_eq!(round.edge_names, vec![format!("F{block}:side:l2|F{block}:top")]);
// Upstream of the fillet, a slot splits the top it bounds.
run(&mut s, "timeline.roll_to", json!({"feature": "Block"}));
slot(&mut s, 5.0);
run(&mut s, "timeline.roll_to", json!({}));
assert_eq!(error(&s, "Round"), None);
// Only one edge runs between the right wall and a piece of the top: the same edge.
assert_eq!(warning(&s, "Round"), None);
let strip = (4.0 - std::f64::consts::PI) * 30.0;
let v = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
assert!((v - (12000.0 - 10.0 * 30.0 * 5.0 - strip)).abs() < 0.05, "{v}");
assert!(!solid_at(&s, [39.9, 15.0, 9.9]), "the right top edge is rounded");
assert!(solid_at(&s, [0.1, 15.0, 9.9]), "the left one is not");
}
#[test]
fn a_piece_keeps_its_identity_when_the_cut_that_made_it_moves_or_shrinks() {
let mut s = Session::default();
block(&mut s);
slot(&mut s, 5.0);
let block = id(&s, "Block");
let cut = id(&s, "Slot");
// Raise the piece right of the slot, picked near the slot.
run(&mut s, "solid.offset_face", json!({"faces": [[27, 15, 10]], "distance": 2, "name": "Raise"}));
let raise = s.doc.find_feature("Raise").cloned().unwrap();
assert_eq!(raise.face_names, vec![format!("F{block}:top#[F{block}:side:l2,F{cut}:side:l2]")]);
assert!(solid_at(&s, [27.0, 15.0, 11.0]) && solid_at(&s, [39.0, 15.0, 11.0]) && !solid_at(&s, [10.0, 15.0, 11.0]));
// The slot moves right past the picked point, which now lies on the other piece: the name,
// not the point, says which piece was raised.
change(&mut s, "slot_x", "28 mm");
assert_eq!(error(&s, "Raise"), None);
assert_eq!(warning(&s, "Raise"), None);
assert!(solid_at(&s, [39.0, 15.0, 11.0]), "the right piece is still the raised one");
assert!(!solid_at(&s, [27.0, 15.0, 11.0]) && !solid_at(&s, [10.0, 15.0, 11.0]), "the left piece is not raised");
// Shrunk to a sliver, the slot still splits the top: the same piece, exactly.
change(&mut s, "slot_w", "0.05 mm");
assert_eq!(warning(&s, "Raise"), None);
assert!(solid_at(&s, [39.0, 15.0, 11.0]) && !solid_at(&s, [27.0, 15.0, 11.0]));
// Gone altogether (suppressed), the piece is the whole top again: said, not guessed.
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
let w = warning(&s, "Raise").unwrap_or_default();
assert!(w.contains("split") || w.contains("no longer exists"), "{w}");
}
#[test]
fn a_face_merged_into_another_answers_to_its_name_and_says_so() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "boss_h", "expression": "5 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 0], "p1": [60, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": "boss_h", "operation": "join", "name": "Boss"}));
let boss = id(&s, "Boss");
run(&mut s, "solid.offset_face", json!({"faces": [[50, 15, 5]], "distance": 1, "name": "Raise"}));
let raise = s.doc.find_feature("Raise").cloned().unwrap();
assert_eq!(raise.face_names, vec![format!("F{boss}:top")]);
assert!(solid_at(&s, [50.0, 15.0, 5.5]) && !solid_at(&s, [50.0, 15.0, 6.5]));
// As tall as the block, the boss's top and the block's are one face: the reference finds
// it and says it was merged.
change(&mut s, "boss_h", "10 mm");
assert_eq!(error(&s, "Raise"), None);
let w = warning(&s, "Raise").unwrap_or_default();
assert!(w.contains("merged"), "{w}");
assert!(solid_at(&s, [50.0, 15.0, 10.5]) && solid_at(&s, [20.0, 15.0, 10.5]), "the merged top is raised as one");
}
#[test]
fn mirrored_twins_keep_their_own_edges() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "hole_x", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "H"}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 15], "radius": 3, "id": "c"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "c.center"], "type": "horizontal", "value": "hole_x"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "c.center"], "type": "vertical", "value": "15 mm"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "H", "distance": 10, "operation": "cut", "name": "Hole"}));
run(&mut s, "solid.mirror", json!({"features": ["Hole"], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}, "name": "Twin"}));
let twin = id(&s, "Twin");
// Chamfer the twin's rim (at x = 30) only.
run(&mut s, "solid.chamfer", json!({"edges": [[33, 15, 10]], "distance": 1, "name": "Bevel"}));
let bevel = s.doc.find_feature("Bevel").cloned().unwrap();
assert!(bevel.edge_names.iter().all(|n| n.contains(&format!("@F{twin}.1"))), "{:?}", bevel.edge_names);
let rim = |c: f64| [c + 3.5, 15.0, 9.8];
assert!(!solid_at(&s, rim(30.0)) && solid_at(&s, rim(10.0)));
// The holes move toward each other: the chamfer's picked point is now off both rims, and
// the twin is the hole nearer it than its source is. Its name says which.
change(&mut s, "hole_x", "14 mm");
assert_eq!(error(&s, "Bevel"), None);
assert_eq!(warning(&s, "Bevel"), None);
assert!(!solid_at(&s, rim(26.0)), "the twin's rim is chamfered");
assert!(solid_at(&s, rim(14.0)), "its source's is not");
}
#[test]
fn pieces_of_a_cut_body_keep_their_body_names_when_the_cut_moves() {
let mut s = Session::default();
block(&mut s);
slot(&mut s, 0.0);
let right = body_at(&s, [39.0, 15.0, 5.0]).unwrap();
let left = body_at(&s, [1.0, 15.0, 5.0]).unwrap();
assert_ne!(right, left);
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 2, "name": "Round"}));
for x in ["5 mm", "22 mm", "15 mm", "2 mm"] {
change(&mut s, "slot_x", x);
assert_eq!(body_at(&s, [39.0, 15.0, 5.0]), Some(right.clone()), "slot at {x}");
assert_eq!(body_at(&s, [1.0, 15.0, 5.0]), Some(left.clone()), "slot at {x}");
assert_eq!(error(&s, "Round"), None, "slot at {x}");
assert_eq!(warning(&s, "Round"), None, "slot at {x}");
assert!(!solid_at(&s, [39.9, 15.0, 9.9]), "slot at {x}");
// Faces of the pieces, of one face cut in two, are told apart across the bodies.
let all: Vec<String> = [&right, &left].iter().flat_map(|b| names(&s, b)).map(|(n, _)| n).collect();
let unique: std::collections::BTreeSet<&String> = all.iter().collect();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
}
#[test]
fn a_name_of_a_body_without_history_is_found_by_geometry_and_says_so_once_it_moves() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "len"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
// Replacing the top by a plane: an operation that reports no history.
run(&mut s, "solid.replace_face", json!({"faces": [[20, 15, 10]], "target": {"origin": [0, 0, 12], "normal": [0, 0, 1]}, "name": "Lift"}));
let state = s.model.state();
let body = state.bodies.first().unwrap();
assert!((0..solvecraft_doc::naming::face_names(body).len()).all(|i| !solvecraft_doc::naming::face_name_exact(body, i)));
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 12]], "radius": 2, "name": "Round"}));
// Where it was picked, the pick and the name agree: nothing to say.
assert_eq!(warning(&s, "Round"), None);
// Moved away from the pick, only geometry found it: the fillet says so.
change(&mut s, "len", "50 mm");
assert_eq!(error(&s, "Round"), None);
let w = warning(&s, "Round").unwrap_or_default();
assert!(w.contains("found by geometry, not exact history"), "{w}");
assert!(!solid_at(&s, [49.9, 15.0, 11.9]));
}
#[test]
fn a_sketch_on_a_certified_face_survives_a_fillet_and_a_cut_upstream_of_it() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "h", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "h", "name": "Block"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 5]], "radius": 3, "name": "Corner"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "H"}));
run(&mut s, "sketch.circle.center", json!({"center": [8, 8], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "H", "distance": 20, "operation": "cut", "name": "Hole"}));
run(&mut s, "sketch.create", json!({"plane": {"face": [25, 18, 10]}, "name": "Top"}));
run(&mut s, "sketch.circle.center", json!({"center": [25, 18], "radius": 3}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": 4, "operation": "join", "name": "Peg"}));
let top = |s: &Session| s.model.state().bodies.iter().map(|b| b.mesh().bounds().max.z).fold(f64::MIN, f64::max);
assert!((top(&s) - 14.0).abs() < 1e-6);
let state = s.model.state();
let body = state.bodies.first().unwrap();
assert!((0..solvecraft_doc::naming::face_names(body).len()).all(|i| solvecraft_doc::naming::face_name_exact(body, i)));
change(&mut s, "h", "16 mm");
for f in ["Corner", "Hole", "Top", "Peg"] {
assert_eq!(error(&s, f), None, "{f}");
assert_eq!(warning(&s, f), None, "{f}");
}
assert!((top(&s) - 20.0).abs() < 1e-6, "{}", top(&s));
}
+5 -3
View File
@@ -106,11 +106,13 @@ fn enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen
let errors = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| (&r.name, e))).collect::<Vec<_>>();
let warnings = session.model.results.iter().filter_map(|r| r.warning.as_ref().map(|e| (&r.name, e))).collect::<Vec<_>>();
assert!(errors.is_empty(), "errors {errors:?}");
// The earlier snap-fit operation retains its documented legacy split-face
// notice. Offset/projection failures or frozen borders are not accepted.
// The earlier snap-fit operation retains its notice about its face reference: the
// shell's inner wall it sits on, which the resized boss join (no exact history there)
// leaves to be found by geometry. Offset/projection failures or frozen borders are not
// accepted.
assert!(
warnings.iter().all(|(name, message)| name.as_str() == "SnapFit1"
&& message.as_str() == "face `F11:side:bs#7` was split; using the piece nearest the picked point"),
&& message.as_str() == "face `F4:inner:F2:side:l1` was found by geometry, not exact history; check it"),
"warnings {warnings:?}"
);
assert!(session.model.results.iter().filter(|r| r.name == "Lid lip").all(|r| r.warning.is_none()));
+9 -2
View File
@@ -1005,9 +1005,16 @@ impl Body {
impl Body {
/// The separate pieces of a body: each outer shell with the voids inside it. A body in one
/// piece comes back as itself.
/// piece comes back as itself. Pieces of a body in several carry their history over
/// `[self]` (their faces and edges are its own), so whoever names them can follow it.
pub fn lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(0.0)
let pieces = self.lumps_with_clearance(0.0)?;
if pieces.len() < 2 {
return Ok(pieces);
}
let counts = [(self.face_count(), self.edge_count())];
let histories = crate::boolean_record::parts_of(&pieces, self);
Ok(pieces.into_iter().zip(histories).map(|(piece, history)| piece.with_checked_history(history, &counts)).collect())
}
/// Whether this body and `other` share material or touch (within a fine mesh tolerance).
+91 -17
View File
@@ -1,22 +1,96 @@
# Persistent naming
A fillet stores the edges it rounds, and a sketch stores the face it sits on. If those were face
and edge *indices*, an upstream edit that adds a face would move the fillet to the wrong edge.
SolidCraft stores **names** instead (`crates/doc/src/naming.rs`):
A fillet stores the edges it rounds, a sketch the face it sits on, a shell the faces it opens.
If those were face and edge *indices*, an upstream edit that adds a face would move the fillet
to another edge. SolveCraft stores **names**, and finds them again every time the timeline
evaluates.
- A face of an evaluated body is named after how it was made. A face lying 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` and `:end`, `F<feature>:blend:<k>` and
so on.
- Pieces of a split face get `#1`, `#2`, … in order along the longest axis.
- An edge is named by the two faces it separates.
- Pattern and mirror copies are named after their sources plus the instance.
## Why names follow the kernel's history
Names are computed lazily, the first time something asks, from the body, the feature that made
it and the model just before that feature. Face provenance from the kernel (which input face an
output face came from) does most of the work.
Names used to be worked out from geometry: sample points of each new face were looked up on the
faces of the feature's input, and the majority won; pieces of a split face were numbered by where
their centres lay. That guess is right most of the time, and wrong exactly when it matters: after
an upstream edit that splits, merges, mirrors or shrinks a face, points can land on a neighbour,
centres can swap order, and a fillet or a sketch quietly re-attaches to a different face. Nothing
tells the user, which is the worst thing a CAD tool can do.
When a feature evaluates, each stored reference is resolved by name. If no face has the name
any more, the reference falls back to the geometry it recorded (a point on the face, a normal),
and the feature shows a warning so the user can check it. A reference that matches nothing is
an error, never a guess.
The kernel now reports, for every operation, what each face and edge of its result came from
(`crates/kernel/src/history.rs`: a face's origins and how it changed, an edge's origin edge or
the faces whose meeting made it). Names follow that history, so they are exact and deterministic,
and when a reference can no longer be found exactly the feature says so instead of guessing.
## How a body's faces are named
`crates/doc/src/lineage.rs` composes the histories of the kernel operations a feature runs (a
tool built from a sketch, cut from each target, the cut body split into lumps) into one
**lineage** over operands that can be named:
| Operand | Its faces are named |
|---|---|
| a body of the model before the feature | as they were |
| a moved copy of one (pattern, mirror) | `<name>@F<feature>.<instance>` |
| the cavity side of a shell grown outward | `F<feature>:inner:<name>` |
| a body the feature built | by role: `F<feature>:side:<sketch curve>`, `start`/`top`/`bottom`/`end` caps, `+x`… box sides, `face:<k>` imported faces |
| a copied tool of a patterned feature | `F<feature>:<role>@F<pattern>.<instance>` |
`crates/doc/src/history_names.rs` names each face of the result from its origins: a kept or
trimmed face keeps its origin's name, a fillet or chamfer face is `F<feature>:blend:<edge>` of the
edge it replaces, a shell's inner wall `F<feature>:inner:<face>`. A side's sketch curve is the
curve the profile segment lies on exactly; nothing is sampled.
- **Pieces.** Faces that end up with one name are pieces of one face, and are told apart by the
faces around them that the others lack: `F2:top#[F2:side:l4,F4:side:l4]` is the piece of the
block's top bounded by its left wall and the slot's left wall. That survives the slot moving,
growing or shrinking. Pieces nothing around them tells apart are numbered (`#1`, `#2`): by what
they came from when that differs (the two halves of a circle's wall), else in kernel order and
then unverified.
- **Merged faces.** A face made of several (a join flush with the body) takes the name of the face
they are all pieces of, else the target's; it answers to the others' names too (aliases).
- **Edges** are named by the faces they separate, `<face A>|<face B>`, `~k` when several edges run
between the same two faces (numbered by where they lie; a reference by such a number is
reported when only geometry confirms it).
- **Lumps.** When a cut leaves a body in pieces, the whole is named first, so pieces of one face in
different bodies are told apart, and the bodies (`Bar`, `Bar (1)`) are assigned in the order of
their least face name, not the kernel's shell order (which an upstream edit can swap).
Names are worked out lazily, on first request, and shared by a body's clones.
### Where there is no history
A body made by an operation that does not report history yet (some kernel paths return none; see
the kernel's handoff lists, and the doc features that do not compose it yet: sheet metal, plastic
and surface features, emboss, rib, replace face, the faceted thread cut), or a face whose role
is not known exactly (a loft's sides, a coil's or pipe's generated profile, a hole's tool), is
named the old way, from geometry, and its names are marked **unverified** (`model.faces` reports
`exact: false`). A reference that resolves through an unverified name while its stored point no
longer lies on the face says so: `face ... was found by geometry, not exact history; check it`.
## How a reference finds its face again
`crates/doc/src/name_lookup.rs`, in order:
1. the face with that name;
2. the one face answering to it: merged into it (the feature says it was merged), or the named
piece, which the faces listed in its name still bound;
3. the pieces of it (it was split, or it is on several bodies): the one nearest the reference's
point, and the feature says it was split;
4. nothing: the feature falls back to the geometry it recorded and says the face no longer exists.
An edge name is found as the edge between the two faces it names, each found as above: when
exactly one edge runs between them it is that edge, even after a split of one of its faces.
## Older designs
Designs saved before format 14 stored names worked out from geometry, and a few of them name a
face that history names differently (a tube's outer wall was named after the sketch curve nearest
its centre, its inner circle). Migrating such a design marks those names (`?F2:top#2`), and they
are found among the names geometry gives, as the build that saved them found them: an older design
evaluates as it was saved. Picking the face again stores its exact name.
## Not done yet
- Certificates (`FaceSource`, the strict attachment identities behind sketches on faces and
configured face styles) still travel only where the kernel's planar-source mapping keeps them;
carrying them through `History::face_sources` would let a certified attachment follow a face a
later join reshapes, which today is refused on purpose.
- Operations without history, listed above, keep the unverified geometric names.