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
@@ -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()));
|
||||
|
||||
Reference in new issue
Block a user