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