From 08cf526fe28beb3b705def152e3389f836117df7 Mon Sep 17 00:00:00 2001 From: Iraklis Kourtis Date: Sun, 11 Oct 2026 21:48:31 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8 --- ROADMAP.md | 8 +- apps/solvecraft-cli/tests/corpus/README.md | 1 + .../corpus/naming-split-pieces.expected.json | 144 ++++++ .../corpus/naming-split-pieces.solvecraft | 373 ++++++++++++++++ .../corpus/scripts/naming-split-pieces.json | 111 +++++ apps/solvecraft-cli/tests/design_corpus.rs | 20 + crates/doc/src/anchor_frame.rs | 2 +- crates/doc/src/appearance.rs | 3 +- crates/doc/src/body_transfer.rs | 2 +- crates/doc/src/clipboard.rs | 8 +- crates/doc/src/config_shell_tests.rs | 12 +- crates/doc/src/eval.rs | 420 +++++++++++------- crates/doc/src/eval_more.rs | 2 +- crates/doc/src/format.rs | 109 ++++- crates/doc/src/history_names.rs | 396 +++++++++++++++++ crates/doc/src/history_names_tests.rs | 68 +++ crates/doc/src/lib.rs | 4 + crates/doc/src/lineage.rs | 388 ++++++++++++++++ crates/doc/src/name_lookup.rs | 333 ++++++++++++++ crates/doc/src/naming.rs | 258 +++++------ crates/doc/src/piece_names.rs | 139 ++++++ crates/doc/src/project.rs | 2 +- .../src/cmd/config_certificate_tests.rs | 29 +- crates/engine/src/cmd/inspect.rs | 5 +- crates/engine/src/lib.rs | 2 + crates/engine/src/naming_history_tests.rs | 273 ++++++++++++ crates/engine/src/pattern_suppress_tests.rs | 8 +- crates/kernel/src/body.rs | 11 +- docs/book/src/dev/naming.md | 108 ++++- 29 files changed, 2917 insertions(+), 322 deletions(-) create mode 100644 apps/solvecraft-cli/tests/corpus/naming-split-pieces.expected.json create mode 100644 apps/solvecraft-cli/tests/corpus/naming-split-pieces.solvecraft create mode 100644 apps/solvecraft-cli/tests/corpus/scripts/naming-split-pieces.json create mode 100644 crates/doc/src/history_names.rs create mode 100644 crates/doc/src/history_names_tests.rs create mode 100644 crates/doc/src/lineage.rs create mode 100644 crates/doc/src/name_lookup.rs create mode 100644 crates/doc/src/piece_names.rs create mode 100644 crates/engine/src/naming_history_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 90dc6e68..25c3b297 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/apps/solvecraft-cli/tests/corpus/README.md b/apps/solvecraft-cli/tests/corpus/README.md index 7215abbc..4dd6ccfe 100644 --- a/apps/solvecraft-cli/tests/corpus/README.md +++ b/apps/solvecraft-cli/tests/corpus/README.md @@ -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. diff --git a/apps/solvecraft-cli/tests/corpus/naming-split-pieces.expected.json b/apps/solvecraft-cli/tests/corpus/naming-split-pieces.expected.json new file mode 100644 index 00000000..ebca3141 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/naming-split-pieces.expected.json @@ -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 + } +} diff --git a/apps/solvecraft-cli/tests/corpus/naming-split-pieces.solvecraft b/apps/solvecraft-cli/tests/corpus/naming-split-pieces.solvecraft new file mode 100644 index 00000000..6cabf4a1 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/naming-split-pieces.solvecraft @@ -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 +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/corpus/scripts/naming-split-pieces.json b/apps/solvecraft-cli/tests/corpus/scripts/naming-split-pieces.json new file mode 100644 index 00000000..3b765377 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/scripts/naming-split-pieces.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/design_corpus.rs b/apps/solvecraft-cli/tests/design_corpus.rs index 0c57b986..6feebdc4 100644 --- a/apps/solvecraft-cli/tests/design_corpus.rs +++ b/apps/solvecraft-cli/tests/design_corpus.rs @@ -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 { diff --git a/crates/doc/src/anchor_frame.rs b/crates/doc/src/anchor_frame.rs index 713a9e76..1759bf89 100644 --- a/crates/doc/src/anchor_frame.rs +++ b/crates/doc/src/anchor_frame.rs @@ -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)) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 12a577d1..08ba9f6c 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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 = (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); diff --git a/crates/doc/src/body_transfer.rs b/crates/doc/src/body_transfer.rs index 38b80cbe..bc629ba5 100644 --- a/crates/doc/src/body_transfer.rs +++ b/crates/doc/src/body_transfer.rs @@ -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; } diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 22837e8c..c4ad4a35 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -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, bodies: &BTreeMap) -> 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, bodies: &BTreeMap 1 && matches!(rest.as_bytes().get(end), Some(b':') | Some(b'.')) diff --git a/crates/doc/src/config_shell_tests.rs b/crates/doc/src/config_shell_tests.rs index 5ecb5b81..18b1aa1b 100644 --- a/crates/doc/src/config_shell_tests.rs +++ b/crates/doc/src/config_shell_tests.rs @@ -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(); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 3b034d9c..a55f2c6a 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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 { 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::>(); &united } else { tools @@ -900,18 +909,20 @@ fn sheet_boolean_changes(body: &Body, tools: &[Body], op: Operation) -> Result Result> { - if tools.len() > 1000 || tools.iter().fold(0usize, |n, b| n.saturating_add(b.face_count())) > 16_384 { +fn intersection_tool_union(tools: Vec) -> Result> { + 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> { } fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, 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, 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, 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 = 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, 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 = 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 { + 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 = Vec::new(); for name in &live { @@ -970,21 +992,21 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, 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 = 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, 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, 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> { +fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketch: Option<&SolvedSketch>) -> Result> { 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, f: &Feature, st: &ModelState) -> Result> { - 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, -) -> Result> { +) -> Result> { + let unnamed = |bodies: Vec| -> Vec { 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::, _>>()?); + .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 ®ions { 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 = 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::>>()?; } @@ -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> { + // 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> { let pts: Vec = 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, ®ions, 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::, _>>()?) + 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, ®ion, *base, axis, pitch, n)?]) + Ok(unnamed(vec![kernel::sweep_helix(&plane, ®ion, *base, axis, pitch, n)?])) } FeatureKind::Loft { sections, .. } => { let mut secs = Vec::new(); @@ -2004,7 +2059,10 @@ fn feature_tools_with_constructor( let [r] = ®ions[..] 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 = 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::>()); + 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 { /// 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, target: &Body, dist_to_start: impl Fn(Vec3) -> f64) -> Result> { +fn trim_to_body(tools: Vec, target: &ModelBody, dist_to_start: impl Fn(Vec3) -> f64) -> Result> { 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)> = 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, 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 = 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, 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, 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, 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, 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 = 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::>()); 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, 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, f: &Feature, st: for p in cells { // The cell: inside the tools that hold the point, outside the others. let inside: Vec = tb.iter().map(|b| b.mesh().contains(*p)).collect(); - let mut acc: Option = None; + let mut acc: Option = 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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> { 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 { diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 52209ab5..feb456f6 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -51,7 +51,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, 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)?; diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 7114a634..e7263aa2 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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 { /// 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; diff --git a/crates/doc/src/history_names.rs b/crates/doc/src/history_names.rs new file mode 100644 index 00000000..af1757e5 --- /dev/null +++ b/crates/doc/src/history_names.rs @@ -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>, + pub(crate) info: Vec, +} + +/// 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, + /// The stems of the faces around it: what tells it apart from other pieces of its face. + pub bounds: BTreeSet, + /// 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, 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>) -> 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, + 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>, +} + +/// 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 { + let h = &lineage.history; + if h.faces.len() != b.body.face_count() || h.edges.len() != b.body.edge_count() { + return None; + } + let operands: Vec = lineage.operands.iter().map(|op| OperandNames::new(op, o)).collect::>()?; + let fid = o.feature.id; + let mut named: Vec> = 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.into_iter().collect::>()?; + 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>(edges: impl Iterator) -> 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, + names: Arc, + /// Its edges' names, worked out on first use (for the faces that blend them). + edges: OnceLock>, +} + +impl<'a> OperandNames<'a> { + fn new(operand: &'a Operand, o: &NamingOrigin) -> Option> { + 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 { + 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 { + let mut origins: Vec = face.origins.clone(); + origins.sort_unstable(); + let mut names: Vec = 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) -> Option { + 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 = 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, adjacent: &[(usize, usize)]) -> Names { + let n = named.len(); + let mut bounds: Vec> = 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> = BTreeMap::new(); + for (i, x) in named.iter().enumerate() { + groups.entry(x.name.as_str()).or_default().push(i); + } + let mut names: Vec = named.iter().map(|x| x.name.clone()).collect(); + let mut verified: Vec = 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::>, |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> = 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>> = 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::>().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 = BTreeMap::new(); + for name in &names { + *seen.entry(name.clone()).or_default() += 1; + } + let mut counter: BTreeMap = 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 { + 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; diff --git a/crates/doc/src/history_names_tests.rs b/crates/doc/src/history_names_tests.rs new file mode 100644 index 00000000..adee0b05 --- /dev/null +++ b/crates/doc/src/history_names_tests.rs @@ -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 { + 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)); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 554215c6..2617b468 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/lineage.rs b/crates/doc/src/lineage.rs new file mode 100644 index 00000000..a883135d --- /dev/null +++ b/crates/doc/src/lineage.rs @@ -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 (`@F.`). + 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:inner:`). + Inner(String), + /// A body the feature built: each face's role (`side:`, `top`, `+x`, …; the first is + /// its name, the others aliases), empty where the role is not known exactly. + Made(Vec>), + /// 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:@F.`). + CopiedTool { roles: Vec>, 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), +} + +/// 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>, +} + +/// 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>, +} + +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 { + 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> = 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> { + 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, +} + +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>>) -> 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(©, &[&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> = 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; 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:` 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>> { + 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 { curves.as_ref()?.find(segments.get(segment)?).map(|id| format!("side:{id}")) }; + let roles = history + .faces + .iter() + .map(|face| { + let mut roles: Vec = 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>> = 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:`. +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 { + 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), +} + +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) -> 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> { + 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) { + let mut families: Vec<(*const Operand, Vec)> = 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> = 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 = 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; + } + } + } +} diff --git a/crates/doc/src/name_lookup.rs b/crates/doc/src/name_lookup.rs new file mode 100644 index 00000000..d9305c92 --- /dev/null +++ b/crates/doc/src/name_lookup.rs @@ -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 { + 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 { + 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)> = 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 { + 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 { + 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 { + 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(best: &mut Option<(Match, Vec)>, 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 { + 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 { + 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 { + 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, name: &str, p: Vec3) -> Option { + 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, name: &str, p: Vec3) -> Option { + 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 { + 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 { + locate_in(std::iter::once((0, body)), name, p) +} diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index 275e13f7..ff23126d 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -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:side:`, `F:start`/`end`, `F:blend:`, …). -//! 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:side:`, `F:start`/`top`/`bottom`/`end`, +//! `F:blend:`, `F:inner:`, …). 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 (`|`, `~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>, - names: Arc>>>, + /// What the body's faces came from, when the feature that made it reports it. + lineage: Option>, + names: Arc>>, + /// The names designs saved before format 14 gave its faces (see [`LEGACY_MARK`]). + legacy: Arc>>>, + /// The body this one is a moved copy of (its legacy names are that body's). + copy_of: Option>, } impl NameCell { pub fn new(origin: Option>) -> 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>, lineage: Option>) -> Self { + NameCell { origin, lineage, ..NameCell::default() } + } + /// Names already known, taken as they are. pub fn known(names: Arc>) -> 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, copy_of: Option>) -> 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> { .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> { + 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 { 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> { + 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> { .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 { +/// The names builds before format 14 gave a body's edges. +pub(crate) fn legacy_edge_names(b: &ModelBody) -> Vec { + 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 { + 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 { // 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 { .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 { .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>)> = None; for (k, mat) in o.mats.iter().enumerate() { @@ -245,7 +335,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec { } } let empty = Vec::new(); - let mut names: Vec = names + let names: Vec = names .into_iter() .enumerate() .map(|(fi, n)| { @@ -261,7 +351,6 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec { }) }) .collect(); - number_pieces(&mut names, &pts, '#'); names } @@ -347,37 +436,23 @@ fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc>)], 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], mark: char) { let mut seen: Vec = 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): `|`, numbered when repeated. pub fn edge_names(b: &ModelBody) -> Vec { + 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 { let m = b.mesh(); - let faces = face_names(b); let mut names: Vec = m .edge_faces .iter() @@ -595,35 +663,7 @@ pub fn edge_names(b: &ModelBody) -> Vec { /// 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 { - 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 = (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 { } /// 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)] diff --git a/crates/doc/src/piece_names.rs b/crates/doc/src/piece_names.rs new file mode 100644 index 00000000..8974c12d --- /dev/null +++ b/crates/doc/src/piece_names.rs @@ -0,0 +1,139 @@ +//! The grammar of piece names: a face split into pieces is named `#[]` +//! (the faces only that piece touches), `#k` after that when nothing tells pieces apart, and +//! designs saved before format 14 numbered pieces `#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 { + 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, + } +} diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index ea5176b5..8188e858 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -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); diff --git a/crates/engine/src/cmd/config_certificate_tests.rs b/crates/engine/src/cmd/config_certificate_tests.rs index da39d20a..e62af0ee 100644 --- a/crates/engine/src/cmd/config_certificate_tests.rs +++ b/crates/engine/src/cmd/config_certificate_tests.rs @@ -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| 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); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 4cdd87fe..cedea1e2 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -262,7 +262,10 @@ fn model_faces(s: &mut Session, p: &Value) -> Result { .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})) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 593c933f..c2befb07 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/naming_history_tests.rs b/crates/engine/src/naming_history_tests.rs new file mode 100644 index 00000000..bdd808a7 --- /dev/null +++ b/crates/engine/src/naming_history_tests.rs @@ -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 { + s.model.result(id(s, name)).and_then(|r| r.warning.clone()) +} + +fn error(s: &Session, name: &str) -> Option { + 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 { + 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 = [&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)); +} diff --git a/crates/engine/src/pattern_suppress_tests.rs b/crates/engine/src/pattern_suppress_tests.rs index b47fcef0..5af88297 100644 --- a/crates/engine/src/pattern_suppress_tests.rs +++ b/crates/engine/src/pattern_suppress_tests.rs @@ -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::>(); let warnings = session.model.results.iter().filter_map(|r| r.warning.as_ref().map(|e| (&r.name, e))).collect::>(); 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())); diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 60c98ad0..c523fe60 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -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> { - 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). diff --git a/docs/book/src/dev/naming.md b/docs/book/src/dev/naming.md index 1338b086..80c3723e 100644 --- a/docs/book/src/dev/naming.md +++ b/docs/book/src/dev/naming.md @@ -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:side:`, `F:start` and `:end`, `F:blend:` 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) | `@F.` | +| the cavity side of a shell grown outward | `F:inner:` | +| a body the feature built | by role: `F:side:`, `start`/`top`/`bottom`/`end` caps, `+x`… box sides, `face:` imported faces | +| a copied tool of a patterned feature | `F:@F.` | + +`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:blend:` of the +edge it replaces, a shell's inner wall `F:inner:`. 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, `|`, `~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.